mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Merge pull request #192 from Sea-Haven-Industries/fix/ab/sh-403-reset-code-hardening
fix(auth): stop reset-code guessing and email enumeration in Forgot Password
This commit is contained in:
commit
de8e283ee5
19 changed files with 5720 additions and 152 deletions
|
|
@ -232,35 +232,45 @@ public class AuthenticationControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_Found_ReturnsCheckEmailMessage()
|
||||
public async Task ForgetPassword_ReturnsTheSameSuccessWhetherOrNotTheEmailIsRegistered()
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.ForgetPassword("a@b.com", CancellationToken.None);
|
||||
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 ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var ok = registered.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var response = ok.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Status.Should().Be("Success ");
|
||||
response.Message.Should().Be("Please check your email for code");
|
||||
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
|
||||
Json(unregistered.Should().BeOfType<OkObjectResult>().Subject.Value).Should().Be(Json(ok.Value));
|
||||
service.Verify(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>()), Times.Once);
|
||||
service.Verify(s => s.ForgetPasswordAsync("x@y.com", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_NotFound_ReturnsNoSuchEmailMessage()
|
||||
public async Task ForgetPassword_WhenServiceThrows_StillAnswersTheSameSuccessAndLogsNoDetail()
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("SECRET a@b.com"));
|
||||
var logger = new Mock<ILogger<AuthenticationController>>();
|
||||
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
|
||||
var controller = new AuthenticationController(service.Object, logger.Object);
|
||||
|
||||
var controller = NewController(service);
|
||||
var result = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None);
|
||||
|
||||
var result = await controller.ForgetPassword("x@y.com", CancellationToken.None);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Status.Should().Be("Error");
|
||||
response.Message.Should().Be("No such email is registered");
|
||||
var response = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
|
||||
logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((state, _) => !state.ToString()!.Contains("a@b.com") && !state.ToString()!.Contains("SECRET")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -269,11 +279,11 @@ public class AuthenticationControllerTests
|
|||
public async Task VerificationCode_MapsServiceResult(bool matched, string expectedStatus, string expectedMessage)
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny<CancellationToken>())).ReturnsAsync(matched);
|
||||
service.Setup(s => s.VerifyCodeAsync("a@b.com", "123456", It.IsAny<CancellationToken>())).ReturnsAsync(matched);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.VerificationCode("123456", CancellationToken.None);
|
||||
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "123456" }, null, null, CancellationToken.None);
|
||||
|
||||
if (matched)
|
||||
{
|
||||
|
|
@ -291,6 +301,33 @@ public class AuthenticationControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationCode_QueryOnlyCode_PassesNoEmailToTheService()
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.VerificationCode(null, null, "123456", 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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationCode_WhenServiceThrows_AnswersTheGenericWrongCodeError()
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
service.Setup(s => s.VerifyCodeAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.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);
|
||||
|
||||
Json(result.Should().BeOfType<BadRequestObjectResult>().Subject.Value)
|
||||
.Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Moq;
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
|
@ -25,7 +26,7 @@ public class AuthenticationServiceTests
|
|||
var (manager, s, h) = IdentityTestHelpers.CreateUserManager();
|
||||
store = s;
|
||||
hasher = h;
|
||||
return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object);
|
||||
return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object, TimeProvider.System);
|
||||
}
|
||||
|
||||
private static JwtOptions JwtOptions => new()
|
||||
|
|
@ -99,31 +100,36 @@ public class AuthenticationServiceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_RegisteredEmail_ReplacesCodeAndSendsEmail()
|
||||
public async Task ForgetPassword_RegisteredEmail_StoresOnlyASaltedHashAndEmailsTheCode()
|
||||
{
|
||||
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>();
|
||||
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; })
|
||||
.Returns(Task.CompletedTask);
|
||||
email.Setup(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.IsAny<string>()))
|
||||
.Callback<string, string, string>((_, _, b) => body = b)
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var service = NewService(userData, forget, email, out _, out _);
|
||||
var before = DateTime.UtcNow;
|
||||
|
||||
var found = await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
|
||||
found.Should().BeTrue();
|
||||
forget.Verify(
|
||||
f => f.ReplaceCodeAsync(
|
||||
user.Email!,
|
||||
user.Id,
|
||||
It.Is<string>(code => code.Length == 6 && code.All(char.IsDigit)),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is<string>(b => b.Contains("Your Password Reset Code is:"))), Times.Once);
|
||||
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();
|
||||
expires.Should().BeCloseTo(before.AddMinutes(15), TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_UnknownEmail_DoesNotEmailOrStoreCode()
|
||||
public async Task ForgetPassword_UnknownEmail_SendsNothingButStillDoesTheDatabaseRoundTrip()
|
||||
{
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
|
||||
|
|
@ -132,40 +138,90 @@ public class AuthenticationServiceTests
|
|||
|
||||
var service = NewService(userData, forget, email, out _, out _);
|
||||
|
||||
var found = await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
|
||||
await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_DeletedAccount_IsTreatedAsUnregistered()
|
||||
{
|
||||
var userData = new Mock<IUserDataService>();
|
||||
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 service = NewService(userData, forget, email, out _, out _);
|
||||
|
||||
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
|
||||
found.Should().BeFalse();
|
||||
forget.Verify(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task VerifyCode_ForwardsDataServiceResult(bool exists)
|
||||
[InlineData(null, "123456")]
|
||||
[InlineData("", "123456")]
|
||||
[InlineData(" ", "123456")]
|
||||
[InlineData("a@b.com", null)]
|
||||
[InlineData("a@b.com", "")]
|
||||
public async Task VerifyCode_WithoutEmailOrCode_FailsWithoutTouchingStoredCodes(string? emailAddress, string? code)
|
||||
{
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny<CancellationToken>())).ReturnsAsync(exists);
|
||||
var forget = new Mock<IForgetPasswordDataService>(MockBehavior.Strict);
|
||||
|
||||
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out _, out _);
|
||||
|
||||
var result = await service.VerifyCodeAsync("abc", CancellationToken.None);
|
||||
var result = await service.VerifyCodeAsync(emailAddress, code, CancellationToken.None);
|
||||
|
||||
result.Should().Be(exists);
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetPassword_NoMatchingCode_ReturnsFalseWithoutReset()
|
||||
public async Task ResetPassword_NoPendingCodeForEmail_ReturnsFalseWithoutReset()
|
||||
{
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||
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 result = await service.ResetPasswordAsync("a@b.com", "999", "new", CancellationToken.None);
|
||||
var result = await service.ResetPasswordAsync("a@b.com", "999999", "new", CancellationToken.None);
|
||||
|
||||
result.Should().BeFalse();
|
||||
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
forget.Verify(f => f.TryConsumeAttemptAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[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 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 result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None);
|
||||
|
||||
result.Should().BeFalse();
|
||||
forget.Verify(f => f.RemoveIssuedThroughAsync("a@b.com", 7, It.IsAny<CancellationToken>()), Times.Once);
|
||||
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("123456", "123456", true)]
|
||||
[InlineData("123456", " 123456 ", true)]
|
||||
[InlineData("123456", "123457", false)]
|
||||
public void ResetCodeHash_IsSaltedAndComparedByValue(string issued, string candidate, bool expected)
|
||||
{
|
||||
var salt = PasswordResetCodeSecrets.NewSalt();
|
||||
var hash = PasswordResetCodeSecrets.Hash(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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using Moq;
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
|
@ -132,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<IEmailSender>(),
|
||||
TimeProvider.System);
|
||||
|
||||
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
|
||||
|
||||
|
|
@ -169,10 +171,19 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
|
|||
{
|
||||
var user = await CreateUserAsync();
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
var salt = PasswordResetCodeSecrets.NewSalt();
|
||||
forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" });
|
||||
.ReturnsAsync(new ForgetPasswordCode
|
||||
{
|
||||
Id = 7,
|
||||
Email = user.Email!,
|
||||
UserId = user.Id,
|
||||
CodeSalt = salt,
|
||||
CodeHash = PasswordResetCodeSecrets.Hash(salt, "123456"),
|
||||
ExpiresAtUtc = DateTime.UtcNow.AddMinutes(10)
|
||||
});
|
||||
forget.Setup(f => f.TryConsumeAttemptAsync(7, It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
var service = NewAuthenticationService(forget);
|
||||
|
||||
var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None);
|
||||
|
|
@ -180,6 +191,7 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
|
|||
reset.Should().BeFalse();
|
||||
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
|
||||
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
forget.Verify(f => f.RefundAttemptAsync(7, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<IdentityError>> ValidateAsync(ApplicationUser user, string password)
|
||||
|
|
@ -208,7 +220,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<IEmailSender>(),
|
||||
TimeProvider.System);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
using Api.SeaHavenIndustries.DTOs;
|
||||
using Api.SeaHavenIndustries.Helper;
|
||||
using Api.SeaHavenIndustries.Infrastructure;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
|
|
@ -108,30 +112,47 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
|
||||
#region Forget Password Area
|
||||
|
||||
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.
|
||||
[AllowAnonymous]
|
||||
[HttpPost()]
|
||||
[Route("ForgetPassword")]
|
||||
public async Task<IActionResult> ForgetPassword(string Email, CancellationToken cancellationToken)
|
||||
{
|
||||
var found = await _authenticationService.ForgetPasswordAsync(Email, cancellationToken);
|
||||
if (found)
|
||||
{
|
||||
return Ok(new Response { Status = "Success ", Message = "Please check your email for code" });
|
||||
}
|
||||
else
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = "No such email is registered" });
|
||||
}
|
||||
}
|
||||
//need email and code
|
||||
[AllowAnonymous]
|
||||
[HttpPost()]
|
||||
[Route("VerificationCode")]
|
||||
public async Task<IActionResult> VerificationCode(string code, CancellationToken cancellationToken)
|
||||
[EnableRateLimiting(PasswordResetRateLimiting.ForgetPasswordPolicy)]
|
||||
public async Task<IActionResult> ForgetPassword(
|
||||
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ForgetPasswordRequest_Dto? body,
|
||||
[FromQuery(Name = "Email")] string? email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await _authenticationService.VerifyCodeAsync(code, cancellationToken))
|
||||
await _authenticationService.ForgetPasswordAsync(body?.Email ?? email, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Same answer as success: a failure only a registered address can hit
|
||||
// must not reveal that the address is registered.
|
||||
LogResetFailure(ex);
|
||||
}
|
||||
|
||||
return Ok(new Response { Status = "Success ", Message = ForgetPasswordMessage });
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost()]
|
||||
[Route("VerificationCode")]
|
||||
[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))
|
||||
{
|
||||
|
||||
return Ok(new Response { Status = "Success ", Message = "Code Matched" });
|
||||
|
|
@ -143,14 +164,15 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
LogResetFailure(ex);
|
||||
return BadRequest(new Response { Status = "Error", Message = "Code Not Matched" });
|
||||
}
|
||||
}
|
||||
|
||||
// need email, password and code
|
||||
[AllowAnonymous]
|
||||
[HttpPost()]
|
||||
[Route("ResetPassword")]
|
||||
[EnableRateLimiting(PasswordResetRateLimiting.ResetPasswordPolicy)]
|
||||
public async Task<IActionResult> ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
|
|
@ -166,11 +188,21 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
|
||||
LogResetFailure(ex);
|
||||
return BadRequest(new Response { Status = "Error", Message = "Your email or code not found please check" });
|
||||
}
|
||||
}
|
||||
|
||||
// These endpoints answer failures exactly like a wrong code or an unknown email, so
|
||||
// an error only a registered account can trigger reveals nothing. Exception
|
||||
// messages here can echo the email or code, so only the type is logged.
|
||||
private void LogResetFailure(Exception exception, [CallerMemberName] string operation = "")
|
||||
{
|
||||
_logger.LogError(
|
||||
"Password reset {Operation} failed with {ExceptionType}.",
|
||||
operation,
|
||||
exception.GetType().FullName);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,20 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
{
|
||||
public class ForgetPassword_Dto
|
||||
{
|
||||
public string Email { get; set; }
|
||||
public string? Email { get; set; }
|
||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
public string? Password { get; set; }
|
||||
public string? Code { get; set; }
|
||||
}
|
||||
|
||||
public class ForgetPasswordRequest_Dto
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
|
||||
public class VerificationCode_Dto
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
public string? Code { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
using System.Net;
|
||||
using System.Threading.RateLimiting;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Per-client limits on the anonymous password reset endpoints, plus the
|
||||
/// forwarded-header trust that makes "per client" mean the caller and not the proxy.
|
||||
/// </summary>
|
||||
public static class PasswordResetRateLimiting
|
||||
{
|
||||
public const string ForgetPasswordPolicy = "password-reset-request";
|
||||
public const string VerificationCodePolicy = "password-reset-verify";
|
||||
public const string ResetPasswordPolicy = "password-reset-confirm";
|
||||
|
||||
public const int PermitLimit = 10;
|
||||
public static readonly TimeSpan Window = TimeSpan.FromMinutes(15);
|
||||
|
||||
public const string RejectedMessage = "Too many requests. Please try again later.";
|
||||
|
||||
/// <summary>
|
||||
/// The API runs on Elastic Beanstalk behind an application load balancer and the
|
||||
/// instance's nginx, so every request reaches Kestrel from loopback. X-Forwarded-For
|
||||
/// is read right to left through loopback and private (VPC) hops only; the first
|
||||
/// public address is the client. Entries a caller writes further left are ignored.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPasswordResetRateLimiting(this IServiceCollection services)
|
||||
{
|
||||
services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
|
||||
options.ForwardLimit = null;
|
||||
options.KnownNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("127.0.0.0"), 8));
|
||||
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("10.0.0.0"), 8));
|
||||
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("172.16.0.0"), 12));
|
||||
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("192.168.0.0"), 16));
|
||||
options.KnownProxies.Add(IPAddress.IPv6Loopback);
|
||||
});
|
||||
|
||||
services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.OnRejected = async (context, cancellationToken) =>
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(
|
||||
new Response { Status = "Error", Message = RejectedMessage },
|
||||
cancellationToken);
|
||||
};
|
||||
|
||||
AddPerClientPolicy(options, ForgetPasswordPolicy);
|
||||
AddPerClientPolicy(options, VerificationCodePolicy);
|
||||
AddPerClientPolicy(options, ResetPasswordPolicy);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static string ClientPartitionKey(HttpContext context)
|
||||
{
|
||||
var address = context.Connection.RemoteIpAddress;
|
||||
if (address == null)
|
||||
return "unknown";
|
||||
return (address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address).ToString();
|
||||
}
|
||||
|
||||
private static void AddPerClientPolicy(RateLimiterOptions options, string policyName)
|
||||
{
|
||||
options.AddPolicy(policyName, context => RateLimitPartition.GetFixedWindowLimiter(
|
||||
ClientPartitionKey(context),
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = PermitLimit,
|
||||
Window = Window,
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@ builder.Services.AddResponseCompression(opts =>
|
|||
opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
|
||||
new[] { "application/octet-stream" });
|
||||
});
|
||||
builder.Services.AddPasswordResetRateLimiting();
|
||||
builder.Services.AddCors(option =>
|
||||
option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
|
||||
|
||||
|
|
@ -195,6 +196,9 @@ builder.Services.AddSwaggerGen(c =>
|
|||
|
||||
var app = builder.Build();
|
||||
|
||||
// First, so every later middleware and the rate limiter see the real client address.
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment() || app.Environment.IsProduction())
|
||||
{
|
||||
|
|
@ -212,6 +216,7 @@ app.UseStaticFiles();
|
|||
app.UseCors();
|
||||
app.UseMiddleware<LegacyDeprecationMiddleware>();
|
||||
app.UseRouting();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<SentryRequestMetadataMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
|
|
|||
4282
Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.Designer.cs
generated
Normal file
4282
Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,70 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class HashPasswordResetCodes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Pending codes were stored in plaintext with no expiry. They are
|
||||
// short-lived, so they are discarded rather than migrated; affected users
|
||||
// request a new code.
|
||||
migrationBuilder.Sql("DELETE FROM [ForgetPasswordCodes];");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CodeHash",
|
||||
table: "ForgetPasswordCodes",
|
||||
type: "nvarchar(64)",
|
||||
maxLength: 64,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CodeSalt",
|
||||
table: "ForgetPasswordCodes",
|
||||
type: "nvarchar(32)",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ExpiresAtUtc",
|
||||
table: "ForgetPasswordCodes",
|
||||
type: "datetime2",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "FailedAttempts",
|
||||
table: "ForgetPasswordCodes",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CodeHash",
|
||||
table: "ForgetPasswordCodes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CodeSalt",
|
||||
table: "ForgetPasswordCodes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExpiresAtUtc",
|
||||
table: "ForgetPasswordCodes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FailedAttempts",
|
||||
table: "ForgetPasswordCodes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1527,10 +1527,26 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CodeHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("CodeSalt")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("FailedAttempts")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
|
@ -11,6 +12,23 @@ namespace Data.SeaHavenIndustries
|
|||
public int Id { get; set; }
|
||||
public string UserId { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Legacy plaintext column. It is always written empty; only
|
||||
/// <see cref="CodeHash"/> is compared.
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Lowercase hex SHA-256 of "<see cref="CodeSalt"/>:code".</summary>
|
||||
[MaxLength(64)]
|
||||
public string CodeHash { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(32)]
|
||||
public string CodeSalt { get; set; } = string.Empty;
|
||||
|
||||
public DateTime ExpiresAtUtc { get; set; }
|
||||
|
||||
/// <summary>Checks consumed against this code, including concurrent ones still in flight.</summary>
|
||||
public int FailedAttempts { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,71 +13,76 @@ namespace SeaHaven.DataServices.Implementation
|
|||
_context = context;
|
||||
}
|
||||
|
||||
public async Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken)
|
||||
public async Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = email.ToLower().Trim();
|
||||
var normalizedEmail = Normalize(email);
|
||||
|
||||
var existing = await _context.ForgetPasswordCodes
|
||||
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
.ToListAsync(cancellationToken);
|
||||
_context.ForgetPasswordCodes.RemoveRange(existing);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
_context.ForgetPasswordCodes.Remove(existing);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var forgetPasswordCode = new ForgetPasswordCode
|
||||
_context.ForgetPasswordCodes.Add(new ForgetPasswordCode
|
||||
{
|
||||
Email = email,
|
||||
UserId = userId,
|
||||
Code = code
|
||||
};
|
||||
|
||||
_context.ForgetPasswordCodes.Add(forgetPasswordCode);
|
||||
Code = string.Empty,
|
||||
CodeHash = codeHash,
|
||||
CodeSalt = codeSalt,
|
||||
ExpiresAtUtc = expiresAtUtc,
|
||||
FailedAttempts = 0
|
||||
});
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> CodeExistsAsync(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.ForgetPasswordCodes
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Code == code)
|
||||
.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = email.ToLower().Trim();
|
||||
var trimmedCode = code.Trim();
|
||||
|
||||
return await _context.ForgetPasswordCodes
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Email.ToLower().Trim() == normalizedEmail && u.Code == trimmedCode)
|
||||
.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = email.ToLower().Trim();
|
||||
var normalizedEmail = Normalize(email);
|
||||
|
||||
return await _context.ForgetPasswordCodes
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
|
||||
.OrderByDescending(u => u.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
// A single conditional UPDATE, so concurrent checks can never consume
|
||||
// more than maxAttempts between them.
|
||||
var updated = await _context.ForgetPasswordCodes
|
||||
.Where(u => u.Id == id && u.FailedAttempts < maxAttempts && u.ExpiresAtUtc > nowUtc)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(u => u.FailedAttempts, u => u.FailedAttempts + 1),
|
||||
cancellationToken);
|
||||
return updated == 1;
|
||||
}
|
||||
|
||||
public async Task RefundAttemptAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.ForgetPasswordCodes
|
||||
.Where(u => u.Id == id && u.FailedAttempts > 0)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(u => u.FailedAttempts, u => u.FailedAttempts - 1),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RemoveByEmailAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = email.ToLower().Trim();
|
||||
var records = await _context.ForgetPasswordCodes
|
||||
var normalizedEmail = Normalize(email);
|
||||
await _context.ForgetPasswordCodes
|
||||
.Where(code => code.Email.ToLower().Trim() == normalizedEmail)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (records.Count == 0)
|
||||
return;
|
||||
|
||||
_context.ForgetPasswordCodes.RemoveRange(records);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = Normalize(email);
|
||||
await _context.ForgetPasswordCodes
|
||||
.Where(code => code.Email.ToLower().Trim() == normalizedEmail && code.Id <= throughId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string Normalize(string email) => email.ToLower().Trim();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,27 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
public interface IForgetPasswordDataService
|
||||
{
|
||||
Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken);
|
||||
Task<bool> CodeExistsAsync(string code, CancellationToken cancellationToken);
|
||||
Task<bool> ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken);
|
||||
/// <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>Returns the pending code for exactly this email, or null.</summary>
|
||||
Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically consumes one attempt when the code is unexpired and has fewer
|
||||
/// than <paramref name="maxAttempts"/> consumed. Returns false otherwise.
|
||||
/// </summary>
|
||||
Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Gives back an attempt consumed by a check that matched.</summary>
|
||||
Task RefundAttemptAsync(int id, CancellationToken cancellationToken);
|
||||
|
||||
Task RemoveByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the email's codes issued up to and including <paramref name="throughId"/>,
|
||||
/// leaving any code issued after it by a concurrent request.
|
||||
/// </summary>
|
||||
Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs
Normal file
40
SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
public static class PasswordResetCodeSecrets
|
||||
{
|
||||
public static string NewCode()
|
||||
{
|
||||
return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static string NewSalt()
|
||||
{
|
||||
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string Hash(string salt, string code)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(salt);
|
||||
ArgumentNullException.ThrowIfNull(code);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static bool Matches(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 expected = Encoding.ASCII.GetBytes(expectedHash);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ using SeaHaven.Services.Helpers;
|
|||
using SeaHaven.Services.Interfaces;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
|
|
@ -21,18 +20,25 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IUserDataService _userDataService;
|
||||
private readonly IForgetPasswordDataService _forgetPasswordDataService;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public static readonly TimeSpan ResetCodeLifetime = TimeSpan.FromMinutes(15);
|
||||
public const int MaxCodeAttempts = 5;
|
||||
|
||||
public AuthenticationService(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IOptions<JwtOptions> jwtOptions,
|
||||
IUserDataService userDataService,
|
||||
IForgetPasswordDataService forgetPasswordDataService,
|
||||
IEmailSender emailSender)
|
||||
IEmailSender emailSender,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_jwtOptions = jwtOptions.Value;
|
||||
_userDataService = userDataService;
|
||||
_forgetPasswordDataService = forgetPasswordDataService;
|
||||
_emailSender = emailSender;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken)
|
||||
|
|
@ -131,48 +137,102 @@ namespace SeaHaven.Services.Implementation
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken)
|
||||
public async Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userDataService.GetByEmailNormalizedAsync(email, cancellationToken);
|
||||
if (user == null) return false;
|
||||
// Registered and unregistered addresses take the same path up to the
|
||||
// email send: one user lookup, one code generated and hashed, one write.
|
||||
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);
|
||||
|
||||
var code = GenerateRandomNo();
|
||||
await _forgetPasswordDataService.ReplaceCodeAsync(user.Email ?? "", user.Id, code, cancellationToken);
|
||||
var body = $"Your Password Reset Code is: " + code;
|
||||
await _emailSender.SendEmailAsync(user.Email ?? email, "Forget Password Request.", body);
|
||||
if (user == null || user.IsDeleted == true || string.IsNullOrWhiteSpace(user.Email))
|
||||
{
|
||||
await _forgetPasswordDataService.RemoveByEmailAsync(requested, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var expiresAtUtc = _timeProvider.GetUtcNow().UtcDateTime.Add(ResetCodeLifetime);
|
||||
await _forgetPasswordDataService.ReplaceCodeAsync(user.Email, user.Id, hash, salt, expiresAtUtc, cancellationToken);
|
||||
var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes.";
|
||||
await _emailSender.SendEmailAsync(user.Email, "Forget Password Request.", body);
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken)
|
||||
{
|
||||
var pending = await CheckCodeAsync(email, code, cancellationToken);
|
||||
if (pending == null)
|
||||
return false;
|
||||
|
||||
// Verifying is a preview step; a correct code keeps all of its attempts.
|
||||
await _forgetPasswordDataService.RefundAttemptAsync(pending.Id, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyCodeAsync(string code, CancellationToken cancellationToken)
|
||||
public async Task<bool> ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _forgetPasswordDataService.CodeExistsAsync(code, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(password))
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return false;
|
||||
|
||||
var matched = await _forgetPasswordDataService.ExistsByEmailAndCodeAsync(email, code, cancellationToken);
|
||||
if (!matched) return false;
|
||||
|
||||
var record = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
|
||||
if (record == null)
|
||||
var pending = await CheckCodeAsync(email, code, cancellationToken);
|
||||
if (pending == null)
|
||||
return false;
|
||||
|
||||
var user = await _userManager.FindByIdAsync(record.UserId);
|
||||
if (user == null)
|
||||
var user = await _userManager.FindByIdAsync(pending.UserId);
|
||||
if (user == null || user.IsDeleted == true)
|
||||
{
|
||||
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var result = await _userManager.ResetPasswordAsync(user, token, password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
// The code was right and the new password was rejected: the user can
|
||||
// try another password without spending an attempt.
|
||||
await _forgetPasswordDataService.RefundAttemptAsync(pending.Id, cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
await _forgetPasswordDataService.RemoveByEmailAsync(email, cancellationToken);
|
||||
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pending code record when <paramref name="code"/> matches the one
|
||||
/// issued to <paramref name="email"/>. Every check consumes an attempt before
|
||||
/// comparing; a check that uses the last attempt without matching deletes the code.
|
||||
/// </summary>
|
||||
private async Task<ForgetPasswordCode?> CheckCodeAsync(string? email, string? code, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(code))
|
||||
return null;
|
||||
|
||||
var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
|
||||
if (pending == null)
|
||||
return null;
|
||||
|
||||
var nowUtc = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
// Deletes below stop at this code's id: a code issued by a concurrent request
|
||||
// after this one was read belongs to that request and must survive.
|
||||
if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken))
|
||||
{
|
||||
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PasswordResetCodeSecrets.Matches(pending.CodeSalt, code, pending.CodeHash))
|
||||
return pending;
|
||||
|
||||
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
|
||||
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
private JwtSecurityToken GetToken(List<Claim> authClaims)
|
||||
{
|
||||
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret));
|
||||
|
|
@ -185,10 +245,5 @@ namespace SeaHaven.Services.Implementation
|
|||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
private static string GenerateRandomNo()
|
||||
{
|
||||
return RandomNumberGenerator.GetInt32(1_000_000).ToString("D6");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<LoginResultDTO> CreateSessionAsync(Data.SeaHavenIndustries.ApplicationUser user, CancellationToken cancellationToken);
|
||||
Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken);
|
||||
Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken);
|
||||
Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken);
|
||||
Task<bool> VerifyCodeAsync(string code, CancellationToken cancellationToken);
|
||||
Task<bool> ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Emails a reset code when the address belongs to an active account. Gives no
|
||||
/// indication either way, so callers cannot learn which emails are registered.
|
||||
/// </summary>
|
||||
Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken);
|
||||
/// <summary>Checks a code against the one issued to this email only.</summary>
|
||||
Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken);
|
||||
Task<bool> ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
406
SeaHavenIndustries.Tests/PasswordResetFlowTests.cs
Normal file
406
SeaHavenIndustries.Tests/PasswordResetFlowTests.cs
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
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 Exhausting_a_code_invalidates_every_pending_code_for_that_email()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
var user = await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var older = host.Sent.LatestCodeFor(Alice);
|
||||
var newest = await host.AddSiblingCodeAsync(Alice, user.Id);
|
||||
|
||||
for (var attempt = 0; attempt < 5; attempt++)
|
||||
await host.VerifyAsync(Alice, WrongCode(newest));
|
||||
|
||||
Assert.Empty(await host.PendingCodesAsync());
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, NewPassword)).Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_successful_reset_invalidates_every_pending_code_for_that_email()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
var user = await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var older = host.Sent.LatestCodeFor(Alice);
|
||||
var newest = await host.AddSiblingCodeAsync(Alice, user.Id);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, newest, NewPassword)).StatusCode);
|
||||
|
||||
Assert.Empty(await host.PendingCodesAsync());
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, "Other@24680")).Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Four_wrong_attempts_then_the_right_code_still_resets()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var code = host.Sent.LatestCodeFor(Alice);
|
||||
|
||||
for (var attempt = 0; attempt < 4; attempt++)
|
||||
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, WrongCode(code))).Content.ReadAsStringAsync());
|
||||
|
||||
// A correct verify does not spend an attempt, so the reset after it still succeeds.
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rejected_new_password_does_not_spend_an_attempt_or_the_code()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var code = host.Sent.LatestCodeFor(Alice);
|
||||
|
||||
for (var attempt = 0; attempt < 6; attempt++)
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "weakpassword")).Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_code_is_checked_only_against_the_email_it_was_issued_to()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.AddUserAsync(Bob, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
await host.ForgetPasswordAsync(Bob);
|
||||
var aliceCode = host.Sent.LatestCodeFor(Alice);
|
||||
var bobCode = host.Sent.LatestCodeFor(Bob);
|
||||
|
||||
if (aliceCode != bobCode)
|
||||
{
|
||||
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Bob, aliceCode)).Content.ReadAsStringAsync());
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Bob, aliceCode, NewPassword)).Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
// Exhausting Bob's attempts leaves Alice's code and budget untouched.
|
||||
for (var attempt = 0; attempt < 5; attempt++)
|
||||
await host.VerifyAsync(Bob, WrongCode(bobCode));
|
||||
|
||||
var alice = Assert.Single(await host.PendingCodesAsync());
|
||||
Assert.Equal(Alice, alice.Email);
|
||||
Assert.Equal(0, alice.FailedAttempts);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, aliceCode, NewPassword)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Bob, OldPassword)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_checks_can_never_consume_more_than_five_attempts()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var row = Assert.Single(await host.PendingCodesAsync());
|
||||
var now = host.Time.GetUtcNow().UtcDateTime;
|
||||
|
||||
var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ =>
|
||||
{
|
||||
await using var scope = host.Services.CreateAsyncScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<IForgetPasswordDataService>()
|
||||
.TryConsumeAttemptAsync(row.Id, 5, now, CancellationToken.None);
|
||||
}));
|
||||
|
||||
Assert.Equal(5, results.Count(consumed => consumed));
|
||||
Assert.Equal(5, Assert.Single(await host.PendingCodesAsync()).FailedAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationCode_without_an_email_fails_generically_and_never_matches_another_users_code()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var code = host.Sent.LatestCodeFor(Alice);
|
||||
|
||||
// The old client contract: code in the query string, no body, no content type.
|
||||
var legacy = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/VerificationCode?code={code}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, legacy.StatusCode);
|
||||
Assert.Equal(CodeNotMatched, await legacy.Content.ReadAsStringAsync());
|
||||
Assert.Equal(0, Assert.Single(await host.PendingCodesAsync()).FailedAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 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);
|
||||
}
|
||||
}
|
||||
118
SeaHavenIndustries.Tests/PasswordResetRaceTests.cs
Normal file
118
SeaHavenIndustries.Tests/PasswordResetRaceTests.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
using System.Net;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A new code requested while a check of the previous code is in flight. The
|
||||
/// request runs for real, between the check's steps, through the same host.
|
||||
/// </summary>
|
||||
public sealed class PasswordResetRaceTests
|
||||
{
|
||||
private const string Alice = "alice@example.com";
|
||||
private const string OldPassword = "Old@12345";
|
||||
private const string NewPassword = "New@67890";
|
||||
|
||||
private static int _nextClient;
|
||||
|
||||
private static string NextClient()
|
||||
{
|
||||
var n = Interlocked.Increment(ref _nextClient);
|
||||
return $"192.0.{n / 250 % 250}.{n % 250 + 1}";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_code_requested_while_the_previous_one_is_being_checked_still_resets()
|
||||
{
|
||||
var race = new CheckRace();
|
||||
await using var host = await PasswordResetTestHost.StartAsync(configureServices: race.Register);
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice, NextClient());
|
||||
var first = host.Sent.LatestCodeFor(Alice);
|
||||
|
||||
// The check has read the first code; the new request replaces it before the attempt is counted.
|
||||
race.BeforeNextConsume = () => host.ForgetPasswordAsync(Alice, NextClient());
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync(Alice, first, NextClient())).StatusCode);
|
||||
|
||||
Assert.Equal(2, host.Sent.Messages.Count);
|
||||
var second = host.Sent.LatestCodeFor(Alice);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword, NextClient())).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_code_requested_while_the_last_wrong_attempt_is_being_checked_still_resets()
|
||||
{
|
||||
var race = new CheckRace();
|
||||
await using var host = await PasswordResetTestHost.StartAsync(configureServices: race.Register);
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice, NextClient());
|
||||
var wrong = host.Sent.LatestCodeFor(Alice) == "000000" ? "111111" : "000000";
|
||||
for (var attempt = 1; attempt < 5; attempt++)
|
||||
await host.VerifyAsync(Alice, wrong, NextClient());
|
||||
|
||||
// The fifth wrong attempt is counted, then the new request lands before the used-up code is deleted.
|
||||
race.AfterNextConsume = () => host.ForgetPasswordAsync(Alice, NextClient());
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync(Alice, wrong, NextClient())).StatusCode);
|
||||
|
||||
Assert.Equal(2, host.Sent.Messages.Count);
|
||||
var second = host.Sent.LatestCodeFor(Alice);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword, NextClient())).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
|
||||
}
|
||||
|
||||
private sealed class CheckRace
|
||||
{
|
||||
private Func<Task>? _beforeNextConsume;
|
||||
private Func<Task>? _afterNextConsume;
|
||||
|
||||
public Func<Task>? BeforeNextConsume { set => _beforeNextConsume = value; }
|
||||
public Func<Task>? AfterNextConsume { set => _afterNextConsume = value; }
|
||||
|
||||
public void Register(IServiceCollection services) =>
|
||||
services.Replace(ServiceDescriptor.Scoped<IForgetPasswordDataService>(provider =>
|
||||
new RacingForgetPasswordDataService(new ForgetPasswordDataService(provider.GetRequiredService<ApplicationDbContext>()), this)));
|
||||
|
||||
public Task RunBeforeConsumeAsync() => Interlocked.Exchange(ref _beforeNextConsume, null)?.Invoke() ?? Task.CompletedTask;
|
||||
public Task RunAfterConsumeAsync() => Interlocked.Exchange(ref _afterNextConsume, null)?.Invoke() ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class RacingForgetPasswordDataService : IForgetPasswordDataService
|
||||
{
|
||||
private readonly IForgetPasswordDataService _inner;
|
||||
private readonly CheckRace _race;
|
||||
|
||||
public RacingForgetPasswordDataService(IForgetPasswordDataService inner, CheckRace race)
|
||||
{
|
||||
_inner = inner;
|
||||
_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<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken) =>
|
||||
_inner.GetByEmailAsync(email, cancellationToken);
|
||||
|
||||
public async Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await _race.RunBeforeConsumeAsync();
|
||||
var consumed = await _inner.TryConsumeAttemptAsync(id, maxAttempts, nowUtc, cancellationToken);
|
||||
await _race.RunAfterConsumeAsync();
|
||||
return consumed;
|
||||
}
|
||||
|
||||
public Task RefundAttemptAsync(int id, CancellationToken cancellationToken) =>
|
||||
_inner.RefundAttemptAsync(id, cancellationToken);
|
||||
|
||||
public Task RemoveByEmailAsync(string email, CancellationToken cancellationToken) =>
|
||||
_inner.RemoveByEmailAsync(email, cancellationToken);
|
||||
|
||||
public Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken) =>
|
||||
_inner.RemoveIssuedThroughAsync(email, throughId, cancellationToken);
|
||||
}
|
||||
}
|
||||
298
SeaHavenIndustries.Tests/PasswordResetTestHost.cs
Normal file
298
SeaHavenIndustries.Tests/PasswordResetTestHost.cs
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Api.SeaHavenIndustries.Infrastructure;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationParts;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SeaHaven.DataServices.DependencyInjection;
|
||||
using SeaHaven.Services.DependencyInjection;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts the real <see cref="AuthenticationController"/> on Kestrel over a SQLite file
|
||||
/// database, with the real authentication service, data services, Identity, and the
|
||||
/// same rate limiting and forwarded-header registration the API host uses. Email goes
|
||||
/// to an in-memory sender, the clock is manual, and every log line is captured.
|
||||
/// </summary>
|
||||
internal sealed class PasswordResetTestHost : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _app;
|
||||
private readonly string _databasePath;
|
||||
|
||||
private PasswordResetTestHost(WebApplication app, string databasePath, HttpClient client)
|
||||
{
|
||||
_app = app;
|
||||
_databasePath = databasePath;
|
||||
Client = client;
|
||||
}
|
||||
|
||||
public HttpClient Client { get; }
|
||||
public ResetEmailOutbox Sent { get; private init; } = null!;
|
||||
public ResetTestClock Time { get; private init; } = null!;
|
||||
public ResetLogCapture Logged { get; private init; } = null!;
|
||||
public IServiceProvider Services => _app.Services;
|
||||
|
||||
public static async Task<PasswordResetTestHost> StartAsync(IEmailSender? emailSender = null, Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
var databasePath = Path.Combine(Path.GetTempPath(), $"password-reset-{Guid.NewGuid():N}.db");
|
||||
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString();
|
||||
|
||||
var sent = new ResetEmailOutbox();
|
||||
var time = new ResetTestClock();
|
||||
var logged = new ResetLogCapture();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" });
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["JWT:Secret"] = new string('k', 64),
|
||||
["JWT:ValidIssuer"] = "issuer",
|
||||
["JWT:ValidAudience"] = "audience"
|
||||
});
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.SetMinimumLevel(LogLevel.Trace);
|
||||
builder.Logging.AddProvider(logged);
|
||||
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite(connectionString));
|
||||
builder.Services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
|
||||
new SqlitePasswordResetDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
|
||||
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
||||
{
|
||||
options.User.RequireUniqueEmail = false;
|
||||
})
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
builder.Services.AddSingleton<TimeProvider>(time);
|
||||
builder.Services.AddSingleton<IEmailSender>(emailSender ?? sent);
|
||||
builder.Services.AddDataServices();
|
||||
configureServices?.Invoke(builder.Services);
|
||||
builder.Services.AddBusinessServices(builder.Configuration);
|
||||
builder.Services.AddControllers()
|
||||
.AddApplicationPart(typeof(AuthenticationController).Assembly)
|
||||
.ConfigureApplicationPartManager(manager =>
|
||||
{
|
||||
manager.FeatureProviders.Clear();
|
||||
manager.FeatureProviders.Add(new OnlyAuthenticationController());
|
||||
});
|
||||
builder.Services.AddPasswordResetRateLimiting();
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseForwardedHeaders();
|
||||
app.UseRouting();
|
||||
app.UseRateLimiter();
|
||||
app.MapControllers();
|
||||
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.EnsureCreatedAsync();
|
||||
|
||||
await app.StartAsync();
|
||||
var address = app.Services.GetRequiredService<IServer>().Features
|
||||
.Get<IServerAddressesFeature>()!.Addresses.Single();
|
||||
|
||||
return new PasswordResetTestHost(app, databasePath, new HttpClient { BaseAddress = new Uri(address) })
|
||||
{
|
||||
Sent = sent,
|
||||
Time = time,
|
||||
Logged = logged
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApplicationUser> AddUserAsync(string email, string password)
|
||||
{
|
||||
await using var scope = _app.Services.CreateAsyncScope();
|
||||
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = email,
|
||||
Email = email,
|
||||
FirstName = "Alice",
|
||||
LastName = "Q",
|
||||
EmailConfirmed = true,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
var result = await users.CreateAsync(user, password);
|
||||
Assert.True(result.Succeeded, string.Join("; ", result.Errors.Select(error => error.Description)));
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task<List<ForgetPasswordCode>> PendingCodesAsync()
|
||||
{
|
||||
await using var scope = _app.Services.CreateAsyncScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>()
|
||||
.ForgetPasswordCodes.AsNoTracking().OrderBy(code => code.Id).ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
const string code = "424242";
|
||||
const string salt = "0123456789abcdef0123456789abcdef";
|
||||
await using var scope = _app.Services.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
context.ForgetPasswordCodes.Add(new ForgetPasswordCode
|
||||
{
|
||||
Email = email,
|
||||
UserId = userId,
|
||||
CodeSalt = salt,
|
||||
CodeHash = SeaHaven.Services.Helpers.PasswordResetCodeSecrets.Hash(salt, code),
|
||||
ExpiresAtUtc = Time.GetUtcNow().UtcDateTime.AddMinutes(15)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
return code;
|
||||
}
|
||||
|
||||
public static HttpRequestMessage Post(string path, object? json = null, string? clientIp = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, path);
|
||||
if (json != null)
|
||||
request.Content = JsonContent.Create(json);
|
||||
if (clientIp != null)
|
||||
request.Headers.Add("X-Forwarded-For", clientIp);
|
||||
return request;
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> ForgetPasswordAsync(string email, string? clientIp = null) =>
|
||||
Client.SendAsync(Post("api/Authentication/ForgetPassword", new { email }, clientIp));
|
||||
|
||||
public Task<HttpResponseMessage> VerifyAsync(string email, string code, string? clientIp = null) =>
|
||||
Client.SendAsync(Post("api/Authentication/VerificationCode", new { email, code }, clientIp));
|
||||
|
||||
public Task<HttpResponseMessage> ResetAsync(string email, string code, string password, string? clientIp = null) =>
|
||||
Client.SendAsync(Post("api/Authentication/ResetPassword", new { email, code, password }, clientIp));
|
||||
|
||||
public Task<HttpResponseMessage> LoginAsync(string username, string password) =>
|
||||
Client.SendAsync(Post("api/Authentication/login", new { username, password }));
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Client.Dispose();
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
SqliteConnection.ClearAllPools();
|
||||
foreach (var path in new[] { _databasePath, _databasePath + "-wal", _databasePath + "-shm", _databasePath + "-journal" })
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OnlyAuthenticationController : ControllerFeatureProvider
|
||||
{
|
||||
protected override bool IsController(System.Reflection.TypeInfo typeInfo) =>
|
||||
typeInfo.AsType() == typeof(AuthenticationController);
|
||||
}
|
||||
|
||||
private sealed class SqlitePasswordResetDbContext : ApplicationDbContext
|
||||
{
|
||||
public SqlitePasswordResetDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
|
||||
{
|
||||
// SQL Server filter syntax does not carry over; a filtered unique index
|
||||
// without its filter would wrongly reject a second user.
|
||||
if (index.GetFilter() is not null)
|
||||
{
|
||||
index.SetFilter(null);
|
||||
index.IsUnique = false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in builder.Model.GetEntityTypes()
|
||||
.SelectMany(entity => entity.GetProperties())
|
||||
.Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[])))
|
||||
{
|
||||
property.ValueGenerated = ValueGenerated.Never;
|
||||
property.IsConcurrencyToken = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ResetTestClock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
||||
}
|
||||
|
||||
internal sealed class ResetEmailOutbox : IEmailSender
|
||||
{
|
||||
private static readonly Regex CodePattern = new(@"Your Password Reset Code is: (\d{6})", RegexOptions.CultureInvariant);
|
||||
|
||||
public ConcurrentQueue<(string To, string Subject, string Body)> Messages { get; } = new();
|
||||
|
||||
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
|
||||
{
|
||||
Messages.Enqueue((emailTo, subject, body));
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public string LatestCodeFor(string email)
|
||||
{
|
||||
var message = Messages.Last(sent => string.Equals(sent.To, email, StringComparison.OrdinalIgnoreCase));
|
||||
return CodePattern.Match(message.Body).Groups[1].Value;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ResetLogCapture : ILoggerProvider
|
||||
{
|
||||
public ConcurrentQueue<string> Lines { get; } = new();
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new CapturingLogger(this, categoryName);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger : ILogger
|
||||
{
|
||||
private readonly ResetLogCapture _owner;
|
||||
private readonly string _category;
|
||||
|
||||
public CapturingLogger(ResetLogCapture owner, string category)
|
||||
{
|
||||
_owner = owner;
|
||||
_category = category;
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
var values = state is IEnumerable<KeyValuePair<string, object?>> pairs
|
||||
? string.Join(" ", pairs.Select(pair => $"{pair.Key}={pair.Value}"))
|
||||
: string.Empty;
|
||||
_owner.Lines.Enqueue($"{logLevel} {_category} {formatter(state, exception)} {values} {exception}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue