From c841e130be3b04829b8bccf82802ecf746840bcd Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:21:29 -0300 Subject: [PATCH 01/10] fix(auth): harden password reset codes against guessing and email enumeration Forgot Password answers every address the same way and emails a code only to an active account. Codes are stored as salted SHA-256 hashes, expire 15 minutes after issue, are replaced by a newer request, and are checked only against the email they were issued to. Five failed checks delete the code; attempts are reserved with one conditional UPDATE so concurrent guesses cannot exceed the budget. VerificationCode requires the email, and email and code are accepted in the JSON body so they stay out of URLs. The three anonymous endpoints are rate limited to 10 requests per 15 minutes per client IP. Forwarded headers are trusted only through loopback and private hops, since the API sits behind the EB load balancer and nginx. The migration adds hash, salt, expiry and attempt columns and deletes the old plaintext rows. --- .../AuthenticationControllerTests.cs | 71 +- .../AuthenticationServiceTests.cs | 110 +- .../Controllers/AuthenticationController.cs | 78 +- .../DTOs/ForgetPassword_Dto.cs | 13 +- .../PasswordResetRateLimiting.cs | 84 + Api.SeaHavenIndustries/Program.cs | 6 + ...5151249_HashPasswordResetCodes.Designer.cs | 4279 +++++++++++++++++ .../20260925151249_HashPasswordResetCodes.cs | 70 + .../ApplicationDbContextModelSnapshot.cs | 16 + .../Models/ForgetPasswordCode.cs | 20 +- .../ForgetPasswordDataService.cs | 90 +- .../Interfaces/IForgetPasswordDataService.cs | 18 +- .../Helpers/PasswordResetCodeSecrets.cs | 40 + .../Implementation/AuthenticationService.cs | 111 +- .../Interfaces/IAuthenticationService.cs | 11 +- .../PasswordResetFlowTests.cs | 375 ++ .../PasswordResetTestHost.cs | 275 ++ 17 files changed, 5520 insertions(+), 147 deletions(-) create mode 100644 Api.SeaHavenIndustries/Infrastructure/PasswordResetRateLimiting.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.cs create mode 100644 SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs create mode 100644 SeaHavenIndustries.Tests/PasswordResetFlowTests.cs create mode 100644 SeaHavenIndustries.Tests/PasswordResetTestHost.cs diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs index 99d5ab8..5ec4371 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs @@ -168,35 +168,45 @@ public class AuthenticationControllerTests } [Fact] - public async Task ForgetPassword_Found_ReturnsCheckEmailMessage() + public async Task ForgetPassword_ReturnsTheSameSuccessWhetherOrNotTheEmailIsRegistered() { var service = new Mock(); - service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny())).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().Subject; + var ok = registered.Should().BeOfType().Subject; var response = ok.Value.Should().BeOfType().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().Subject.Value).Should().Be(Json(ok.Value)); + service.Verify(s => s.ForgetPasswordAsync("a@b.com", It.IsAny()), Times.Once); + service.Verify(s => s.ForgetPasswordAsync("x@y.com", It.IsAny()), Times.Once); } [Fact] - public async Task ForgetPassword_NotFound_ReturnsNoSuchEmailMessage() + public async Task ForgetPassword_WhenServiceThrows_StillAnswersTheSameSuccessAndLogsNoDetail() { var service = new Mock(); - service.Setup(s => s.ForgetPasswordAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + service.Setup(s => s.ForgetPasswordAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("SECRET a@b.com")); + var logger = new Mock>(); + logger.Setup(x => x.IsEnabled(It.IsAny())).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().Subject; - var response = bad.Value.Should().BeOfType().Subject; - response.Status.Should().Be("Error"); - response.Message.Should().Be("No such email is registered"); + var response = result.Should().BeOfType().Subject.Value.Should().BeOfType().Subject; + response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage); + logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((state, _) => !state.ToString()!.Contains("a@b.com") && !state.ToString()!.Contains("SECRET")), + null, + It.IsAny>()), + Times.Once); } [Theory] @@ -205,11 +215,11 @@ public class AuthenticationControllerTests public async Task VerificationCode_MapsServiceResult(bool matched, string expectedStatus, string expectedMessage) { var service = new Mock(); - service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny())).ReturnsAsync(matched); + service.Setup(s => s.VerifyCodeAsync("a@b.com", "123456", It.IsAny())).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) { @@ -227,6 +237,33 @@ public class AuthenticationControllerTests } } + [Fact] + public async Task VerificationCode_QueryOnlyCode_PassesNoEmailToTheService() + { + var service = new Mock(); + var controller = NewController(service); + + var result = await controller.VerificationCode(null, null, "123456", CancellationToken.None); + + result.Should().BeOfType().Subject.Value.Should().BeOfType() + .Which.Message.Should().Be("Code Not Matched"); + service.Verify(s => s.VerifyCodeAsync(null, "123456", It.IsAny()), Times.Once); + } + + [Fact] + public async Task VerificationCode_WhenServiceThrows_AnswersTheGenericWrongCodeError() + { + var service = new Mock(); + service.Setup(s => s.VerifyCodeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .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().Subject.Value) + .Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" })); + } + [Fact] public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage() { diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index 7d9aa08..1e1c1f0 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -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(); userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(user); var forget = new Mock(); var email = new Mock(); + string? stored = null, salt = null, body = null; + DateTime expires = default; + forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, h, s, e, _) => { stored = h; salt = s; expires = e; }) + .Returns(Task.CompletedTask); + email.Setup(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.IsAny())) + .Callback((_, _, 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(code => code.Length == 6 && code.All(char.IsDigit)), - It.IsAny()), - Times.Once); - email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is(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(); userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).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()), Times.Once); + forget.Verify(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + email.Verify(e => e.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ForgetPassword_DeletedAccount_IsTreatedAsUnregistered() + { + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(IdentityTestHelpers.User(isDeleted: true)); + var forget = new Mock(); + var email = new Mock(); + + 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(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); email.Verify(e => e.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), 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(); - forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny())).ReturnsAsync(exists); + var forget = new Mock(MockBehavior.Strict); var service = NewService(new Mock(), forget, new Mock(), 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(); - forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(false); + forget.Setup(f => f.GetByEmailAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ForgetPasswordCode?)null); var service = NewService(new Mock(), forget, new Mock(), 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(), It.IsAny()), Times.Never); - forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + forget.Verify(f => f.TryConsumeAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), 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(); + forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny())).ReturnsAsync(pending); + forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var service = NewService(new Mock(), forget, new Mock(), out var store, out _); + + var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None); + + result.Should().BeFalse(); + forget.Verify(f => f.RemoveAsync(7, It.IsAny()), Times.Once); + store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), 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(); } } diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 12fd8fa..469c1e9 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -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 @@ -104,30 +108,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 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 VerificationCode(string code, CancellationToken cancellationToken) + [EnableRateLimiting(PasswordResetRateLimiting.ForgetPasswordPolicy)] + public async Task 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 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" }); @@ -139,14 +160,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 ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken) { try @@ -162,11 +184,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 } } diff --git a/Api.SeaHavenIndustries/DTOs/ForgetPassword_Dto.cs b/Api.SeaHavenIndustries/DTOs/ForgetPassword_Dto.cs index 3ad9bae..1a06931 100644 --- a/Api.SeaHavenIndustries/DTOs/ForgetPassword_Dto.cs +++ b/Api.SeaHavenIndustries/DTOs/ForgetPassword_Dto.cs @@ -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; } + } } diff --git a/Api.SeaHavenIndustries/Infrastructure/PasswordResetRateLimiting.cs b/Api.SeaHavenIndustries/Infrastructure/PasswordResetRateLimiting.cs new file mode 100644 index 0000000..a9b25d5 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/PasswordResetRateLimiting.cs @@ -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; + +/// +/// 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. +/// +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."; + + /// + /// 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. + /// + public static IServiceCollection AddPasswordResetRateLimiting(this IServiceCollection services) + { + services.Configure(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 + })); + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6baf11f..5dd1d2f 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -1,5 +1,6 @@ using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.HostedServices; +using Api.SeaHavenIndustries.Infrastructure; using Api.SeaHavenIndustries.Middleware; using Api.SeaHavenIndustries.Observability; using Api.SeaHavenIndustries.Options; @@ -63,6 +64,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())); @@ -199,6 +201,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()) { @@ -216,6 +221,7 @@ app.UseStaticFiles(); app.UseCors(); app.UseMiddleware(); app.UseRouting(); +app.UseRateLimiter(); app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); diff --git a/Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.Designer.cs new file mode 100644 index 0000000..a38743c --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.Designer.cs @@ -0,0 +1,4279 @@ +// +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260925151249_HashPasswordResetCodes")] + partial class HashPasswordResetCodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("WorkOrderInternalNumberSequence"); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsAccountOwner") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PendingRegistration") + .HasColumnType("bit"); + + b.Property("PendingRegistrationCreatedDate") + .HasColumnType("datetime2"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("IsAccountOwner") + .IsUnique() + .HasDatabaseName("IX_AspNetUsers_IsAccountOwner") + .HasFilter("IsAccountOwner = 1"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalSourceEmailS3Key") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExtraSafetyNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplateProcedure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId", "SortOrder"); + + b.ToTable("CompletionDocTemplateProcedures"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteContactOrder") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EscalatedAt") + .HasColumnType("datetime2"); + + b.Property("EvidenceDocumentId") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("InitialNotificationSentAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NotificationError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RequestKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId") + .IsUnique() + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CodeSalt") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("FailedAttempts") + .HasColumnType("int"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("PermissionKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("State") + .HasColumnType("int"); + + b.HasKey("UserId", "PermissionKey"); + + b.HasIndex("UserId", "PermissionKey") + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + + b.ToTable("UserPermissionOverrides"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Area") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("UserId", "Area"); + + b.HasIndex("UserId", "Area") + .IsUnique() + .HasDatabaseName("IX_UserServiceAreas_UserId_Area"); + + b.ToTable("UserServiceAreas"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AdditionalContacts") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvetaRequired") + .HasColumnType("bit"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalAssignedTo") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalRecordType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalWorkOrderId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + + b.Property("FrozenCompany") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenPoc") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenSite") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsAddOn") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ServiceId") + .HasColumnType("int"); + + b.Property("ServiceNameSnapshot") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("ServiceId"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderExternalReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VersionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Kind", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId"); + + b.ToTable("WorkOrderExternalReceipts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderReconciliationJob", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CommentsProcessed") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ErrorCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FenceToken") + .HasColumnType("uniqueidentifier"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("RunId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("WorkOrdersProcessed") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.ToTable("WorkOrderReconciliationJobs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.ToTable("WorkOrderWebhookDeliveries"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("SourceWeekStart") + .HasColumnType("date"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "SourceWeekStart") + .IsUnique(); + + b.ToTable("WorkOrderWeekRolledLedgers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplateProcedure", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany("Procedures") + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany("Contacts") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "EvidenceDocument") + .WithMany() + .HasForeignKey("EvidenceDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("EvidenceDocument"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany() + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.HasOne("Data.SeaHavenIndustries.Service", "Service") + .WithMany("SupportedWorkOrderTypes") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany("ServiceAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.HasOne("Data.SeaHavenIndustries.Area", "Area") + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Area"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + + b.Navigation("ServiceDefinition"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("ServiceAreas"); + + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Navigation("Procedures"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("CompletionDocuments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Contacts"); + + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Service", b => + { + b.Navigation("SupportedWorkOrderTypes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("AuditLogs"); + + b.Navigation("Dispatches"); + + b.Navigation("SitePreferences"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Navigation("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.cs b/Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.cs new file mode 100644 index 0000000..9ad344b --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class HashPasswordResetCodes : Migration + { + /// + 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( + name: "CodeHash", + table: "ForgetPasswordCodes", + type: "nvarchar(64)", + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "CodeSalt", + table: "ForgetPasswordCodes", + type: "nvarchar(32)", + maxLength: 32, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ExpiresAtUtc", + table: "ForgetPasswordCodes", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "FailedAttempts", + table: "ForgetPasswordCodes", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + 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"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 795219d..81570fc 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1527,10 +1527,26 @@ namespace Data.SeaHavenIndustries.Migrations .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CodeSalt") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + b.Property("Email") .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("FailedAttempts") + .HasColumnType("int"); + b.Property("UserId") .IsRequired() .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Models/ForgetPasswordCode.cs b/Data.SeaHavenIndustries/Models/ForgetPasswordCode.cs index 6a59aeb..3573851 100644 --- a/Data.SeaHavenIndustries/Models/ForgetPasswordCode.cs +++ b/Data.SeaHavenIndustries/Models/ForgetPasswordCode.cs @@ -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; } + + /// + /// Legacy plaintext column. It is always written empty; only + /// is compared. + /// + public string Code { get; set; } = string.Empty; + + /// Lowercase hex SHA-256 of ":code". + [MaxLength(64)] + public string CodeHash { get; set; } = string.Empty; + + [MaxLength(32)] + public string CodeSalt { get; set; } = string.Empty; + + public DateTime ExpiresAtUtc { get; set; } + + /// Checks consumed against this code, including concurrent ones still in flight. + public int FailedAttempts { get; set; } } } diff --git a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs index c1781a2..7eec75f 100644 --- a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs +++ b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs @@ -13,71 +13,75 @@ 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 CodeExistsAsync(string code, CancellationToken cancellationToken) - { - return await _context.ForgetPasswordCodes - .AsNoTracking() - .Where(u => u.Code == code) - .AnyAsync(cancellationToken); - } - - public async Task 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 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 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 RemoveAsync(int id, CancellationToken cancellationToken) + { + await _context.ForgetPasswordCodes + .Where(u => u.Id == id) + .ExecuteDeleteAsync(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); } + + private static string Normalize(string email) => email.ToLower().Trim(); } } diff --git a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs index 720e665..e145aa9 100644 --- a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs @@ -4,10 +4,22 @@ namespace SeaHaven.DataServices.Interfaces { public interface IForgetPasswordDataService { - Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken); - Task CodeExistsAsync(string code, CancellationToken cancellationToken); - Task ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken); + /// Deletes every pending code for the email, then stores the new one. + Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken); + + /// Returns the pending code for exactly this email, or null. Task GetByEmailAsync(string email, CancellationToken cancellationToken); + + /// + /// Atomically consumes one attempt when the code is unexpired and has fewer + /// than consumed. Returns false otherwise. + /// + Task TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken); + + /// Gives back an attempt consumed by a check that matched. + Task RefundAttemptAsync(int id, CancellationToken cancellationToken); + + Task RemoveAsync(int id, CancellationToken cancellationToken); Task RemoveByEmailAsync(string email, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs b/SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs new file mode 100644 index 0000000..39762dd --- /dev/null +++ b/SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Generation and hashing for emailed password reset codes. The raw code exists + /// only in memory and in the email sent to the account holder. + /// + 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); + } + } +} diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index d86f19a..fa90dff 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -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 userManager, IOptions 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 LoginAsync(string? username, string? password, CancellationToken cancellationToken) @@ -111,48 +117,100 @@ namespace SeaHaven.Services.Implementation }; } - public async Task 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 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 VerifyCodeAsync(string code, CancellationToken cancellationToken) + public async Task ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken) { - return await _forgetPasswordDataService.CodeExistsAsync(code, cancellationToken); - } - - public async Task 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.RemoveAsync(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.RemoveAsync(pending.Id, cancellationToken); return true; } + /// + /// Returns the pending code record when matches the one + /// issued to . Every check consumes an attempt before + /// comparing; a check that uses the last attempt without matching deletes the code. + /// + private async Task 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; + if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken)) + { + await _forgetPasswordDataService.RemoveAsync(pending.Id, cancellationToken); + return null; + } + + if (PasswordResetCodeSecrets.Matches(pending.CodeSalt, code, pending.CodeHash)) + return pending; + + if (pending.FailedAttempts + 1 >= MaxCodeAttempts) + await _forgetPasswordDataService.RemoveAsync(pending.Id, cancellationToken); + return null; + } + private JwtSecurityToken GetToken(List authClaims) { var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret)); @@ -165,10 +223,5 @@ namespace SeaHaven.Services.Implementation ); return token; } - - private static string GenerateRandomNo() - { - return RandomNumberGenerator.GetInt32(1_000_000).ToString("D6"); - } } } diff --git a/SeaHaven.Services/Interfaces/IAuthenticationService.cs b/SeaHaven.Services/Interfaces/IAuthenticationService.cs index 426eb5f..210efd6 100644 --- a/SeaHaven.Services/Interfaces/IAuthenticationService.cs +++ b/SeaHaven.Services/Interfaces/IAuthenticationService.cs @@ -7,8 +7,13 @@ namespace SeaHaven.Services.Interfaces Task LoginAsync(string? username, string? password, CancellationToken cancellationToken); Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken); Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken); - Task ForgetPasswordAsync(string email, CancellationToken cancellationToken); - Task VerifyCodeAsync(string code, CancellationToken cancellationToken); - Task ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken); + /// + /// 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. + /// + Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken); + /// Checks a code against the one issued to this email only. + Task VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken); + Task ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken); } } diff --git a/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs b/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs new file mode 100644 index 0000000..97b3630 --- /dev/null +++ b/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs @@ -0,0 +1,375 @@ +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; + +/// +/// The anonymous Forgot Password flow over HTTP, against the real controller, +/// services, Identity and a relational database. +/// +public sealed class PasswordResetFlowTests +{ + private const string Alice = "alice@example.com"; + private const string Bob = "bob@example.com"; + private const string OldPassword = "Old@12345"; + private const string NewPassword = "New@67890"; + private const string CodeNotMatched = "{\"status\":\"Error\",\"message\":\"Code Not Matched\"}"; + private const string ResetFailed = "{\"status\":\"Error\",\"message\":\"Your email or code not found please check\"}"; + + [Fact] + public async Task Registered_user_resets_password_end_to_end_and_signs_in_with_the_new_one() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + + var requested = await host.ForgetPasswordAsync(Alice); + Assert.Equal(HttpStatusCode.OK, requested.StatusCode); + var code = host.Sent.LatestCodeFor(Alice); + Assert.Matches("^[0-9]{6}$", code); + + var verified = await host.VerifyAsync(Alice, code); + Assert.Equal(HttpStatusCode.OK, verified.StatusCode); + Assert.Contains("Code Matched", await verified.Content.ReadAsStringAsync()); + + var reset = await host.ResetAsync(Alice, code, NewPassword); + Assert.Equal(HttpStatusCode.OK, reset.StatusCode); + + Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await host.LoginAsync(Alice, OldPassword)).StatusCode); + + // A used code cannot be replayed. + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "Other@24680")).Content.ReadAsStringAsync()); + Assert.Empty(await host.PendingCodesAsync()); + } + + [Fact] + public async Task ForgetPassword_answers_registered_and_unregistered_emails_identically_and_emails_only_the_registered_one() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + + var registered = await host.ForgetPasswordAsync(Alice); + var unregistered = await host.ForgetPasswordAsync("nobody@example.com"); + + Assert.Equal(HttpStatusCode.OK, registered.StatusCode); + Assert.Equal(registered.StatusCode, unregistered.StatusCode); + Assert.Equal(await registered.Content.ReadAsStringAsync(), await unregistered.Content.ReadAsStringAsync()); + Assert.Contains(AuthenticationController.ForgetPasswordMessage, await registered.Content.ReadAsStringAsync()); + + var message = Assert.Single(host.Sent.Messages); + Assert.Equal(Alice, message.To); + Assert.Single(await host.PendingCodesAsync()); + } + + [Fact] + public async Task ForgetPassword_hides_an_email_delivery_failure_behind_the_same_response() + { + await using var host = await PasswordResetTestHost.StartAsync(new ThrowingEmailSender()); + await host.AddUserAsync(Alice, OldPassword); + + var registered = await host.ForgetPasswordAsync(Alice); + var unregistered = await host.ForgetPasswordAsync("nobody@example.com"); + + Assert.Equal(HttpStatusCode.OK, registered.StatusCode); + var body = await registered.Content.ReadAsStringAsync(); + Assert.Equal(await unregistered.Content.ReadAsStringAsync(), body); + Assert.DoesNotContain("smtp-internal-detail", body); + Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(Alice, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Stored_code_is_a_salted_hash_and_the_plaintext_is_only_in_the_email() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + + await host.ForgetPasswordAsync(Alice); + var code = host.Sent.LatestCodeFor(Alice); + var row = Assert.Single(await host.PendingCodesAsync()); + + Assert.Equal(string.Empty, row.Code); + Assert.Matches("^[0-9a-f]{32}$", row.CodeSalt); + Assert.Matches("^[0-9a-f]{64}$", row.CodeHash); + Assert.NotEqual(Sha256Hex(code), row.CodeHash); + Assert.Equal(Sha256Hex(row.CodeSalt + ":" + code), row.CodeHash); + Assert.DoesNotContain(code, string.Join("|", row.Code, row.CodeHash, row.CodeSalt, row.Email, row.UserId)); + } + + [Fact] + public async Task Code_expires_fifteen_minutes_after_it_is_issued() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var code = host.Sent.LatestCodeFor(Alice); + + host.Time.Advance(TimeSpan.FromMinutes(15) - TimeSpan.FromSeconds(1)); + Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode); + + host.Time.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync()); + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync()); + Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode); + } + + [Fact] + public async Task Requesting_a_new_code_invalidates_the_previous_one() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + + await host.ForgetPasswordAsync(Alice); + var first = host.Sent.LatestCodeFor(Alice); + string second; + do + { + await host.ForgetPasswordAsync(Alice); + second = host.Sent.LatestCodeFor(Alice); + } + while (second == first); + + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, first)).Content.ReadAsStringAsync()); + Assert.Single(await host.PendingCodesAsync()); + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword)).StatusCode); + } + + [Fact] + public async Task Five_wrong_attempts_invalidate_the_code_until_a_new_one_is_requested() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var code = host.Sent.LatestCodeFor(Alice); + var wrong = WrongCode(code); + + // Verify and reset share one attempt budget. + for (var attempt = 0; attempt < 3; attempt++) + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, wrong)).Content.ReadAsStringAsync()); + for (var attempt = 0; attempt < 2; attempt++) + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, wrong, NewPassword)).Content.ReadAsStringAsync()); + + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync()); + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync()); + Assert.Empty(await host.PendingCodesAsync()); + Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode); + + await host.ForgetPasswordAsync(Alice); + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, host.Sent.LatestCodeFor(Alice), NewPassword)).StatusCode); + } + + [Fact] + public async Task Four_wrong_attempts_then_the_right_code_still_resets() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var code = host.Sent.LatestCodeFor(Alice); + + for (var attempt = 0; attempt < 4; attempt++) + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, WrongCode(code))).Content.ReadAsStringAsync()); + + // A correct verify does not spend an attempt, so the reset after it still succeeds. + Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode); + } + + [Fact] + public async Task A_rejected_new_password_does_not_spend_an_attempt_or_the_code() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var code = host.Sent.LatestCodeFor(Alice); + + for (var attempt = 0; attempt < 6; attempt++) + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "weakpassword")).Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode); + } + + [Fact] + public async Task A_code_is_checked_only_against_the_email_it_was_issued_to() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.AddUserAsync(Bob, OldPassword); + await host.ForgetPasswordAsync(Alice); + await host.ForgetPasswordAsync(Bob); + var aliceCode = host.Sent.LatestCodeFor(Alice); + var bobCode = host.Sent.LatestCodeFor(Bob); + + if (aliceCode != bobCode) + { + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Bob, aliceCode)).Content.ReadAsStringAsync()); + Assert.Equal(ResetFailed, await (await host.ResetAsync(Bob, aliceCode, NewPassword)).Content.ReadAsStringAsync()); + } + + // Exhausting Bob's attempts leaves Alice's code and budget untouched. + for (var attempt = 0; attempt < 5; attempt++) + await host.VerifyAsync(Bob, WrongCode(bobCode)); + + var alice = Assert.Single(await host.PendingCodesAsync()); + Assert.Equal(Alice, alice.Email); + Assert.Equal(0, alice.FailedAttempts); + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, aliceCode, NewPassword)).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Bob, OldPassword)).StatusCode); + } + + [Fact] + public async Task Concurrent_checks_can_never_consume_more_than_five_attempts() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + await host.ForgetPasswordAsync(Alice); + var row = Assert.Single(await host.PendingCodesAsync()); + var now = host.Time.GetUtcNow().UtcDateTime; + + var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ => + { + await using var scope = host.Services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService() + .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 SendEmailAsync(string emailTo, string subject, string body) => + throw new InvalidOperationException("smtp-internal-detail " + emailTo); + } +} diff --git a/SeaHavenIndustries.Tests/PasswordResetTestHost.cs b/SeaHavenIndustries.Tests/PasswordResetTestHost.cs new file mode 100644 index 0000000..bbfe044 --- /dev/null +++ b/SeaHavenIndustries.Tests/PasswordResetTestHost.cs @@ -0,0 +1,275 @@ +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; + +/// +/// Hosts the real 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. +/// +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 CapturingEmailSender Sent { get; private init; } = null!; + public ManualTimeProvider Time { get; private init; } = null!; + public CapturingLoggerProvider Logged { get; private init; } = null!; + public IServiceProvider Services => _app.Services; + + public static async Task StartAsync(IEmailSender? emailSender = 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 CapturingEmailSender(); + var time = new ManualTimeProvider(); + var logged = new CapturingLoggerProvider(); + + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" }); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["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(options => options.UseSqlite(connectionString)); + builder.Services.Replace(ServiceDescriptor.Scoped(provider => + new SqlitePasswordResetDbContext(provider.GetRequiredService>()))); + builder.Services.AddIdentity(options => + { + options.User.RequireUniqueEmail = false; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + builder.Services.AddSingleton(time); + builder.Services.AddSingleton(emailSender ?? sent); + builder.Services.AddDataServices(); + 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().Database.EnsureCreatedAsync(); + + await app.StartAsync(); + var address = app.Services.GetRequiredService().Features + .Get()!.Addresses.Single(); + + return new PasswordResetTestHost(app, databasePath, new HttpClient { BaseAddress = new Uri(address) }) + { + Sent = sent, + Time = time, + Logged = logged + }; + } + + public async Task AddUserAsync(string email, string password) + { + await using var scope = _app.Services.CreateAsyncScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + 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> PendingCodesAsync() + { + await using var scope = _app.Services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService() + .ForgetPasswordCodes.AsNoTracking().OrderBy(code => code.Id).ToListAsync(); + } + + 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 ForgetPasswordAsync(string email, string? clientIp = null) => + Client.SendAsync(Post("api/Authentication/ForgetPassword", new { email }, clientIp)); + + public Task VerifyAsync(string email, string code, string? clientIp = null) => + Client.SendAsync(Post("api/Authentication/VerificationCode", new { email, code }, clientIp)); + + public Task ResetAsync(string email, string code, string password, string? clientIp = null) => + Client.SendAsync(Post("api/Authentication/ResetPassword", new { email, code, password }, clientIp)); + + public Task 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 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 ManualTimeProvider : 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 CapturingEmailSender : 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 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 CapturingLoggerProvider : ILoggerProvider +{ + public ConcurrentQueue Lines { get; } = new(); + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(this, categoryName); + + public void Dispose() + { + } + + private sealed class CapturingLogger : ILogger + { + private readonly CapturingLoggerProvider _owner; + private readonly string _category; + + public CapturingLogger(CapturingLoggerProvider owner, string category) + { + _owner = owner; + _category = category; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var values = state is IEnumerable> pairs + ? string.Join(" ", pairs.Select(pair => $"{pair.Key}={pair.Value}")) + : string.Empty; + _owner.Lines.Enqueue($"{logLevel} {_category} {formatter(state, exception)} {values} {exception}"); + } + } +} From bcc9b6d7a247a02f2ef4bc700a20396fba3f9c7f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:31:17 -0300 Subject: [PATCH 02/10] fix(auth): invalidate every pending reset code for an email together Two concurrent first requests can leave two pending codes for one email. Exhausting or using one now deletes all of them, so a sibling code cannot become live afterwards. --- .../AuthenticationServiceTests.cs | 2 +- .../ForgetPasswordDataService.cs | 7 ----- .../Interfaces/IForgetPasswordDataService.cs | 1 - .../Implementation/AuthenticationService.cs | 8 ++--- .../PasswordResetFlowTests.cs | 31 +++++++++++++++++++ .../PasswordResetTestHost.cs | 22 +++++++++++++ 6 files changed, 58 insertions(+), 13 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index 1e1c1f0..ef8a297 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -206,7 +206,7 @@ public class AuthenticationServiceTests var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None); result.Should().BeFalse(); - forget.Verify(f => f.RemoveAsync(7, It.IsAny()), Times.Once); + forget.Verify(f => f.RemoveByEmailAsync("a@b.com", It.IsAny()), Times.Once); store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); } diff --git a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs index 7eec75f..7e3b3c4 100644 --- a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs +++ b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs @@ -67,13 +67,6 @@ namespace SeaHaven.DataServices.Implementation cancellationToken); } - public async Task RemoveAsync(int id, CancellationToken cancellationToken) - { - await _context.ForgetPasswordCodes - .Where(u => u.Id == id) - .ExecuteDeleteAsync(cancellationToken); - } - public async Task RemoveByEmailAsync(string email, CancellationToken cancellationToken) { var normalizedEmail = Normalize(email); diff --git a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs index e145aa9..a7de61e 100644 --- a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs @@ -19,7 +19,6 @@ namespace SeaHaven.DataServices.Interfaces /// Gives back an attempt consumed by a check that matched. Task RefundAttemptAsync(int id, CancellationToken cancellationToken); - Task RemoveAsync(int id, CancellationToken cancellationToken); Task RemoveByEmailAsync(string email, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index fa90dff..1a0d263 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -164,7 +164,7 @@ namespace SeaHaven.Services.Implementation var user = await _userManager.FindByIdAsync(pending.UserId); if (user == null || user.IsDeleted == true) { - await _forgetPasswordDataService.RemoveAsync(pending.Id, cancellationToken); + await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); return false; } @@ -178,7 +178,7 @@ namespace SeaHaven.Services.Implementation return false; } - await _forgetPasswordDataService.RemoveAsync(pending.Id, cancellationToken); + await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); return true; } @@ -199,7 +199,7 @@ namespace SeaHaven.Services.Implementation var nowUtc = _timeProvider.GetUtcNow().UtcDateTime; if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken)) { - await _forgetPasswordDataService.RemoveAsync(pending.Id, cancellationToken); + await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); return null; } @@ -207,7 +207,7 @@ namespace SeaHaven.Services.Implementation return pending; if (pending.FailedAttempts + 1 >= MaxCodeAttempts) - await _forgetPasswordDataService.RemoveAsync(pending.Id, cancellationToken); + await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); return null; } diff --git a/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs b/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs index 97b3630..51f3c70 100644 --- a/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs +++ b/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs @@ -163,6 +163,37 @@ public sealed class PasswordResetFlowTests 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() { diff --git a/SeaHavenIndustries.Tests/PasswordResetTestHost.cs b/SeaHavenIndustries.Tests/PasswordResetTestHost.cs index bbfe044..6904954 100644 --- a/SeaHavenIndustries.Tests/PasswordResetTestHost.cs +++ b/SeaHavenIndustries.Tests/PasswordResetTestHost.cs @@ -137,6 +137,28 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable .ForgetPasswordCodes.AsNoTracking().OrderBy(code => code.Id).ToListAsync(); } + /// + /// 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. + /// + public async Task 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(); + 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); From 561873a7973a839705f32bcd83ed2bd3488a9152 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:39:36 -0300 Subject: [PATCH 03/10] chore(migrations): regenerate the reset-code migration on the current main model --- ....cs => 20260925153843_HashPasswordResetCodes.Designer.cs} | 5 ++++- ...esetCodes.cs => 20260925153843_HashPasswordResetCodes.cs} | 0 2 files changed, 4 insertions(+), 1 deletion(-) rename Data.SeaHavenIndustries/Migrations/{20260925151249_HashPasswordResetCodes.Designer.cs => 20260925153843_HashPasswordResetCodes.Designer.cs} (99%) rename Data.SeaHavenIndustries/Migrations/{20260925151249_HashPasswordResetCodes.cs => 20260925153843_HashPasswordResetCodes.cs} (100%) diff --git a/Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.Designer.cs similarity index 99% rename from Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.Designer.cs rename to Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.Designer.cs index a38743c..59a88ee 100644 --- a/Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.Designer.cs +++ b/Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.Designer.cs @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Data.SeaHavenIndustries.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20260925151249_HashPasswordResetCodes")] + [Migration("20260925153843_HashPasswordResetCodes")] partial class HashPasswordResetCodes { /// @@ -1635,6 +1635,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Name") .HasColumnType("nvarchar(max)"); + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + b.Property("PhoneNumber") .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.cs b/Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.cs similarity index 100% rename from Data.SeaHavenIndustries/Migrations/20260925151249_HashPasswordResetCodes.cs rename to Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.cs From 77a10e38caa6b292fe5e0e9fccb3816eb4012745 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:00:03 -0300 Subject: [PATCH 04/10] fix(auth): cap reset abuse per account, key code hashes, send reset email off the request path - Forgot Password is limited to 3 codes an hour and 10 a day per email, and an account gets 10 failed code checks a day across every code it is sent, so new client addresses and new codes no longer buy more guesses. Refused requests answer exactly like accepted ones. - The reset email is queued to a background sender, and unregistered addresses store a row no code can match, so both paths do the same work and return without waiting on the mail provider. Each request also clears expired codes. - Code hashes are HMAC-SHA256 under a key derived with HKDF from the JWT signing secret; rows in the previous unkeyed format stop matching. - Email and code are read only from the JSON body. --- .../AuthenticationControllerTests.cs | 16 +- .../AuthenticationServiceTests.cs | 72 +++--- .../Controllers/AuthenticationController.cs | 11 +- .../PasswordResetEmailDelivery.cs | 99 +++++++ Api.SeaHavenIndustries/Program.cs | 1 + .../ForgetPasswordDataService.cs | 21 +- .../Interfaces/IForgetPasswordDataService.cs | 9 +- .../DependencyInjection/ServicesModule.cs | 3 + .../Helpers/InMemoryPasswordResetThrottle.cs | 119 +++++++++ .../Helpers/PasswordResetCodeSecrets.cs | 35 ++- .../Implementation/AuthenticationService.cs | 70 +++-- .../Interfaces/IPasswordResetEmailQueue.cs | 13 + .../Interfaces/IPasswordResetThrottle.cs | 24 ++ .../PasswordResetAbuseLimitsTests.cs | 241 ++++++++++++++++++ .../PasswordResetFlowTests.cs | 30 ++- .../PasswordResetTestHost.cs | 33 ++- 16 files changed, 702 insertions(+), 95 deletions(-) create mode 100644 Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs create mode 100644 SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs create mode 100644 SeaHaven.Services/Interfaces/IPasswordResetEmailQueue.cs create mode 100644 SeaHaven.Services/Interfaces/IPasswordResetThrottle.cs create mode 100644 SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs index 5ec4371..14a61da 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs @@ -173,8 +173,8 @@ public class AuthenticationControllerTests var service = new Mock(); 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().Subject; var response = ok.Value.Should().BeOfType().Subject; @@ -195,7 +195,7 @@ public class AuthenticationControllerTests logger.Setup(x => x.IsEnabled(It.IsAny())).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().Subject.Value.Should().BeOfType().Subject; response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage); @@ -219,7 +219,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) { @@ -238,16 +238,16 @@ public class AuthenticationControllerTests } [Fact] - public async Task VerificationCode_QueryOnlyCode_PassesNoEmailToTheService() + public async Task VerificationCode_WithoutABody_PassesNoEmailOrCodeToTheService() { var service = new Mock(); 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().Subject.Value.Should().BeOfType() .Which.Message.Should().Be("Code Not Matched"); - service.Verify(s => s.VerifyCodeAsync(null, "123456", It.IsAny()), Times.Once); + service.Verify(s => s.VerifyCodeAsync(null, null, It.IsAny()), Times.Once); } [Fact] @@ -258,7 +258,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().Subject.Value) .Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" })); diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index ef8a297..6b539d2 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -19,14 +19,14 @@ public class AuthenticationServiceTests private static AuthenticationService NewService( Mock userData, Mock forget, - Mock email, + Mock email, out Mock> store, out Mock> hasher) { 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, TimeProvider.System); + return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object, new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System); } private static JwtOptions JwtOptions => new() @@ -39,7 +39,7 @@ public class AuthenticationServiceTests [Fact] public async Task Login_UnknownUser_ReturnsNull() { - var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out _); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out _); store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ApplicationUser?)null); var result = await service.LoginAsync("nobody", "pw", CancellationToken.None); @@ -51,7 +51,7 @@ public class AuthenticationServiceTests public async Task Login_DeletedUser_RejectedBeforePasswordCheck() { var deletedUser = IdentityTestHelpers.User(isDeleted: true); - var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(deletedUser); store.Setup(s => s.GetRolesAsync(deletedUser, It.IsAny())).ReturnsAsync(new List()); @@ -65,7 +65,7 @@ public class AuthenticationServiceTests public async Task Login_ValidUser_ReturnsTokenFirstRoleAndIdentity() { var user = IdentityTestHelpers.User(); - var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); store.Setup(s => s.GetRolesAsync(user, It.IsAny())).ReturnsAsync(new List { "Admin", "Manager" }); store.As>() @@ -88,7 +88,7 @@ public class AuthenticationServiceTests public async Task Login_BadPassword_ReturnsNull() { var user = IdentityTestHelpers.User(); - var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); store.As>() .Setup(s => s.GetPasswordHashAsync(user, It.IsAny())).ReturnsAsync("hash"); @@ -99,22 +99,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(); userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(user); var forget = new Mock(); - var email = new Mock(); + var email = new Mock(); string? stored = null, salt = null, body = null; DateTime expires = default; - forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, _, h, s, e, _) => { stored = h; salt = s; expires = e; }) + forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, h, s, e, _, _) => { stored = h; salt = s; expires = e; }) .Returns(Task.CompletedTask); - email.Setup(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.IsAny())) + email.Setup(e => e.TryEnqueue(user.Email!, "Forget Password Request.", It.IsAny())) .Callback((_, _, b) => body = b) - .ReturnsAsync(true); + .Returns(true); var service = NewService(userData, forget, email, out _, out _); var before = DateTime.UtcNow; @@ -124,25 +126,27 @@ 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(); - userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ApplicationUser?)null); - var forget = new Mock(); - var email = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(IdentityTestHelpers.User()); + var registered = new Mock(); + var unregistered = new Mock(); + var email = new Mock(); - var service = NewService(userData, forget, email, out _, out _); + 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); - await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None); - - forget.Verify(f => f.RemoveByEmailAsync("nope@example.com", It.IsAny()), Times.Once); - forget.Verify(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - email.Verify(e => e.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + 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(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + email.Verify(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -152,13 +156,13 @@ public class AuthenticationServiceTests userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(IdentityTestHelpers.User(isDeleted: true)); var forget = new Mock(); - var email = new Mock(); + var email = new Mock(); var service = NewService(userData, forget, email, out _, out _); await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); - email.Verify(e => e.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + email.Verify(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Theory] @@ -171,7 +175,7 @@ public class AuthenticationServiceTests { var forget = new Mock(MockBehavior.Strict); - var service = NewService(new Mock(), forget, new Mock(), out _, out _); + var service = NewService(new Mock(), forget, new Mock(), out _, out _); var result = await service.VerifyCodeAsync(emailAddress, code, CancellationToken.None); @@ -184,7 +188,7 @@ public class AuthenticationServiceTests var forget = new Mock(); forget.Setup(f => f.GetByEmailAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ForgetPasswordCode?)null); - var service = NewService(new Mock(), forget, new Mock(), out var store, out _); + var service = NewService(new Mock(), forget, new Mock(), out var store, out _); var result = await service.ResetPasswordAsync("a@b.com", "999999", "new", CancellationToken.None); @@ -196,12 +200,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(); forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny())).ReturnsAsync(pending); forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny(), It.IsAny())).ReturnsAsync(false); - var service = NewService(new Mock(), forget, new Mock(), out var store, out _); + var service = NewService(new Mock(), forget, new Mock(), out var store, out _); var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None); @@ -217,11 +221,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(); } } diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 469c1e9..1c4ddf4 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -111,20 +111,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 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) { @@ -142,13 +141,11 @@ namespace Api.SeaHavenIndustries.Controllers [EnableRateLimiting(PasswordResetRateLimiting.VerificationCodePolicy)] public async Task 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" }); diff --git a/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs b/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs new file mode 100644 index 0000000..348073f --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs @@ -0,0 +1,99 @@ +using System.Threading.Channels; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public static class PasswordResetEmailDelivery + { + /// + /// 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. + /// + public static IServiceCollection AddPasswordResetEmailDelivery(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddHostedService(); + 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 _channel = Channel.CreateBounded( + new BoundedChannelOptions(Capacity) + { + FullMode = BoundedChannelFullMode.DropWrite, + SingleReader = true + }); + private readonly ILogger _logger; + private int _pending; + + public PasswordResetEmailChannel(ILogger logger) + { + _logger = logger; + } + + public ChannelReader Reader => _channel.Reader; + + /// Emails accepted and not yet handed to the mail provider. + 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 _logger; + + public PasswordResetEmailSenderHostedService( + PasswordResetEmailChannel channel, + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _channel = channel; + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var email in _channel.Reader.ReadAllAsync(stoppingToken)) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var sender = scope.ServiceProvider.GetRequiredService(); + if (!await sender.SendEmailAsync(email.EmailTo, email.Subject, email.Body)) + _logger.LogWarning("Password reset email was not accepted by the mail provider."); + } + 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); + } + finally + { + _channel.MarkHandled(); + } + } + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 5dd1d2f..073a64a 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -65,6 +65,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())); diff --git a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs index 7e3b3c4..c824fc9 100644 --- a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs +++ b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs @@ -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 GetByEmailAsync(string email, CancellationToken cancellationToken) diff --git a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs index a7de61e..c34e9ba 100644 --- a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs @@ -4,8 +4,13 @@ namespace SeaHaven.DataServices.Interfaces { public interface IForgetPasswordDataService { - /// Deletes every pending code for the email, then stores the new one. - Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken); + /// + /// In one transaction, deletes every pending code for the email and every expired + /// code, then stores the new one. + /// + Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, DateTime nowUtc, CancellationToken cancellationToken); + + Task PurgeExpiredAsync(DateTime nowUtc, CancellationToken cancellationToken); /// Returns the pending code for exactly this email, or null. Task GetByEmailAsync(string email, CancellationToken cancellationToken); diff --git a/SeaHaven.Services/DependencyInjection/ServicesModule.cs b/SeaHaven.Services/DependencyInjection/ServicesModule.cs index e24dec3..a6c2f53 100644 --- a/SeaHaven.Services/DependencyInjection/ServicesModule.cs +++ b/SeaHaven.Services/DependencyInjection/ServicesModule.cs @@ -73,6 +73,9 @@ namespace SeaHaven.Services.DependencyInjection services.AddValidatorsFromAssembly(assembly); + // Process-wide counters: a scoped instance would start empty on every request. + services.AddSingleton(); + services.AddScoped( sp => (IWorkOrderReconciliationRunner)sp.GetRequiredService()); diff --git a/SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs b/SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs new file mode 100644 index 0000000..00ad7aa --- /dev/null +++ b/SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs @@ -0,0 +1,119 @@ +using System.Security.Cryptography; +using System.Text; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Process-wide sliding-window counters for . + /// 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. + /// + 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 _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 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); + } + } + + /// The same normalization the user lookup applies: trimmed, invariant upper case. + 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 Requests { get; } = new(); + public List FailedChecks { get; } = new(); + + public void Prune(DateTimeOffset cutoff) + { + Requests.RemoveAll(at => at <= cutoff); + FailedChecks.RemoveAll(at => at <= cutoff); + } + } + } +} diff --git a/SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs b/SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs index 39762dd..edee811 100644 --- a/SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs +++ b/SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs @@ -6,10 +6,24 @@ namespace SeaHaven.Services.Helpers { /// /// 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. /// public static class PasswordResetCodeSecrets { + private static readonly byte[] KeyInfo = Encoding.UTF8.GetBytes("password-reset-code-v1"); + + /// + /// 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. + /// + public static byte[] DeriveKey(string serverSecret) + { + ArgumentException.ThrowIfNullOrEmpty(serverSecret); + return HKDF.DeriveKey(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(serverSecret), 32, Array.Empty(), 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) + /// A value shaped like a hash that no code can match. + 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); } diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index 1a0d263..94e0833 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -19,28 +19,37 @@ 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 byte[]? _resetCodeKey; public static readonly TimeSpan ResetCodeLifetime = TimeSpan.FromMinutes(15); public const int MaxCodeAttempts = 5; + /// Longest address stored for a reset request; Identity caps emails at 256. + public const int MaxResetEmailLength = 256; + public AuthenticationService( UserManager userManager, IOptions jwtOptions, IUserDataService userDataService, IForgetPasswordDataService forgetPasswordDataService, - IEmailSender emailSender, + IPasswordResetEmailQueue resetEmails, + IPasswordResetThrottle resetThrottle, TimeProvider timeProvider) { _userManager = userManager; _jwtOptions = jwtOptions.Value; _userDataService = userDataService; _forgetPasswordDataService = forgetPasswordDataService; - _emailSender = emailSender; + _resetEmails = resetEmails; + _resetThrottle = resetThrottle; _timeProvider = timeProvider; } + private byte[] ResetCodeKey => _resetCodeKey ??= PasswordResetCodeSecrets.DeriveKey(_jwtOptions.Secret); + public async Task LoginAsync(string? username, string? password, CancellationToken cancellationToken) { var user = await _userManager.FindByNameAsync(username ?? ""); @@ -119,26 +128,42 @@ 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 body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes."; - await _emailSender.SendEmailAsync(user.Email, "Forget Password Request.", body); + 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); + + await _forgetPasswordDataService.ReplaceCodeAsync( + active ? user!.Email! : requested, + active ? user!.Id : string.Empty, + active ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(), + salt, + nowUtc.Add(ResetCodeLifetime), + nowUtc, + cancellationToken); + + if (active) + { + var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes."; + _resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body); + } } public async Task VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken) @@ -192,6 +217,12 @@ 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 only when the code matches. + if (!_resetThrottle.TryReserveCheck(email)) + return null; + var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken); if (pending == null) return null; @@ -203,8 +234,11 @@ namespace SeaHaven.Services.Implementation return null; } - if (PasswordResetCodeSecrets.Matches(pending.CodeSalt, code, pending.CodeHash)) + if (PasswordResetCodeSecrets.Matches(ResetCodeKey, pending.CodeSalt, code, pending.CodeHash)) + { + _resetThrottle.ReleaseCheck(email); return pending; + } if (pending.FailedAttempts + 1 >= MaxCodeAttempts) await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); diff --git a/SeaHaven.Services/Interfaces/IPasswordResetEmailQueue.cs b/SeaHaven.Services/Interfaces/IPasswordResetEmailQueue.cs new file mode 100644 index 0000000..948f2d0 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IPasswordResetEmailQueue.cs @@ -0,0 +1,13 @@ +namespace SeaHaven.Services.Interfaces +{ + /// + /// 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. + /// + public interface IPasswordResetEmailQueue + { + /// Returns false when the queue is full and the email was dropped. + bool TryEnqueue(string emailTo, string subject, string body); + } +} diff --git a/SeaHaven.Services/Interfaces/IPasswordResetThrottle.cs b/SeaHaven.Services/Interfaces/IPasswordResetThrottle.cs new file mode 100644 index 0000000..8cdcc2e --- /dev/null +++ b/SeaHaven.Services/Interfaces/IPasswordResetThrottle.cs @@ -0,0 +1,24 @@ +namespace SeaHaven.Services.Interfaces +{ + /// + /// Per-account limits on the anonymous password reset flow, keyed on the + /// normalized email so they hold however many client addresses an attacker uses. + /// + public interface IPasswordResetThrottle + { + /// + /// 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. + /// + bool TryAcceptCodeRequest(string email); + + /// + /// 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. + /// + bool TryReserveCheck(string email); + + /// Gives back the reservation of a check whose code matched. + void ReleaseCheck(string email); + } +} diff --git a/SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs b/SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs new file mode 100644 index 0000000..2431cc4 --- /dev/null +++ b/SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs @@ -0,0 +1,241 @@ +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; + +/// +/// Limits that hold per account rather than per client address, the email send +/// being off the request path, and the keyed code hash. +/// +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(); + for (var request = 0; request < 3; request++) + responses.Add(await (await host.ForgetPasswordAsync(Alice, NextClient())).Content.ReadAsStringAsync()); + var liveCode = host.Sent.LatestCodeFor(Alice); + + // A differently cased and padded address is the same account. + responses.Add(await (await host.ForgetPasswordAsync(" ALICE@Example.com ", NextClient())).Content.ReadAsStringAsync()); + + Assert.Equal(3, host.Sent.Messages.Count); + Assert.Single(responses.Distinct()); + // The refused request neither sent a code nor replaced the one already sent. + Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, liveCode, NextClient())).StatusCode); + + for (var hour = 1; hour <= 3; hour++) + { + host.Time.Advance(TimeSpan.FromHours(1)); + for (var request = 0; request < 3; request++) + await host.ForgetPasswordAsync(Alice, NextClient()); + } + + Assert.Equal(10, host.Sent.Messages.Count); + + host.Time.Advance(TimeSpan.FromHours(20)); + await host.ForgetPasswordAsync(Alice, NextClient()); + Assert.Equal(10, host.Sent.Messages.Count); + + host.Time.Advance(TimeSpan.FromHours(1)); + await host.ForgetPasswordAsync(Alice, NextClient()); + Assert.Equal(11, host.Sent.Messages.Count); + } + + [Fact] + public async Task Failed_checks_are_capped_per_account_across_every_code_it_is_sent() + { + await using var host = await PasswordResetTestHost.StartAsync(); + await host.AddUserAsync(Alice, OldPassword); + + for (var round = 0; round < 2; round++) + { + await host.ForgetPasswordAsync(Alice, NextClient()); + var code = host.Sent.LatestCodeFor(Alice); + for (var attempt = 0; attempt < 5; attempt++) + await host.VerifyAsync(Alice, WrongCode(code), NextClient()); + } + + await host.ForgetPasswordAsync(Alice, NextClient()); + var third = host.Sent.LatestCodeFor(Alice); + + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, third, NextClient())).Content.ReadAsStringAsync()); + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, third, NewPassword, NextClient())).Content.ReadAsStringAsync()); + Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode); + + host.Time.Advance(TimeSpan.FromDays(1)); + await host.ForgetPasswordAsync(Alice, NextClient()); + Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, host.Sent.LatestCodeFor(Alice), NewPassword, NextClient())).StatusCode); + } + + [Fact] + public void Concurrent_checks_can_never_exceed_the_account_budget() + { + var throttle = new InMemoryPasswordResetThrottle(new ManualTimeProvider()); + + var reserved = 0; + Parallel.For(0, 64, _ => + { + if (throttle.TryReserveCheck(Alice)) + Interlocked.Increment(ref reserved); + }); + + Assert.Equal(InMemoryPasswordResetThrottle.FailedChecksPerDay, reserved); + } + + [Fact] + public void A_matching_check_gives_its_reservation_back() + { + var throttle = new InMemoryPasswordResetThrottle(new ManualTimeProvider()); + + for (var check = 0; check < 50; check++) + { + Assert.True(throttle.TryReserveCheck(Alice)); + throttle.ReleaseCheck(Alice); + } + + Assert.True(throttle.TryReserveCheck(Alice.ToUpperInvariant())); + } + + [Fact] + public async Task ForgetPassword_answers_before_the_email_is_sent() + { + var sender = new BlockingEmailSender(); + await using var host = await PasswordResetTestHost.StartAsync(sender); + await host.AddUserAsync(Alice, OldPassword); + try + { + var response = await host.Client + .SendAsync(PasswordResetTestHost.Post("api/Authentication/ForgetPassword", new { email = Alice })) + .WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains(AuthenticationController.ForgetPasswordMessage, await response.Content.ReadAsStringAsync()); + Assert.False(sender.Completed); + } + finally + { + sender.Release(); + } + + await host.WaitForEmailDrainAsync(); + Assert.True(sender.Completed); + } + + [Fact] + public async Task An_unregistered_email_gets_the_same_database_write_but_a_code_nothing_can_match() + { + await using var host = await PasswordResetTestHost.StartAsync(); + var alice = await host.AddUserAsync(Alice, OldPassword); + + await host.ForgetPasswordAsync(Alice); + await host.ForgetPasswordAsync("nobody@example.com"); + + var rows = await host.PendingCodesAsync(); + Assert.Equal(2, rows.Count); + var registered = Assert.Single(rows, row => row.Email == Alice); + var unregistered = Assert.Single(rows, row => row.Email == "nobody@example.com"); + Assert.Equal(alice.Id, registered.UserId); + Assert.Equal(string.Empty, unregistered.UserId); + Assert.Matches("^[0-9a-f]{64}$", unregistered.CodeHash); + Assert.Equal(registered.ExpiresAtUtc, unregistered.ExpiresAtUtc); + Assert.Single(host.Sent.Messages); + + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync("nobody@example.com", "000000")).Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Each_request_clears_expired_codes_so_decoy_rows_do_not_accumulate() + { + await using var host = await PasswordResetTestHost.StartAsync(); + + await host.ForgetPasswordAsync("first@example.com"); + await host.ForgetPasswordAsync("second@example.com"); + host.Time.Advance(TimeSpan.FromMinutes(16)); + await host.ForgetPasswordAsync("third@example.com"); + + var row = Assert.Single(await host.PendingCodesAsync()); + Assert.Equal("third@example.com", row.Email); + } + + [Fact] + public async Task A_code_stored_in_the_previous_unkeyed_format_no_longer_matches() + { + await using var host = await PasswordResetTestHost.StartAsync(); + var alice = await host.AddUserAsync(Alice, OldPassword); + var code = await host.AddSiblingCodeAsync(Alice, alice.Id, keyedHash: false); + + Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync()); + Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync()); + } + + [Fact] + public void A_code_hashed_under_another_server_secret_does_not_match() + { + var salt = PasswordResetCodeSecrets.NewSalt(); + var hash = PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.DeriveKey(new string('a', 64)), salt, "123456"); + + Assert.True(PasswordResetCodeSecrets.Matches(PasswordResetCodeSecrets.DeriveKey(new string('a', 64)), salt, "123456", hash)); + Assert.False(PasswordResetCodeSecrets.Matches(PasswordResetCodeSecrets.DeriveKey(new string('b', 64)), salt, "123456", hash)); + } + + [Fact] + public async Task Reset_queue_and_throttle_are_process_wide_and_the_sender_runs() + { + await using var host = await PasswordResetTestHost.StartAsync(); + + object Resolve() where T : notnull + { + using var scope = host.Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + Assert.Same(Resolve(), Resolve()); + Assert.Same(Resolve(), Resolve()); + Assert.Same(Resolve(), Resolve()); + Assert.Contains(host.Services.GetServices(), 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 SendEmailAsync(string emailTo, string subject, string body) + { + await _gate.Task; + Completed = true; + return true; + } + } +} diff --git a/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs b/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs index 51f3c70..fe4984d 100644 --- a/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs +++ b/SeaHavenIndustries.Tests/PasswordResetFlowTests.cs @@ -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); diff --git a/SeaHavenIndustries.Tests/PasswordResetTestHost.cs b/SeaHavenIndustries.Tests/PasswordResetTestHost.cs index 6904954..a16774e 100644 --- a/SeaHavenIndustries.Tests/PasswordResetTestHost.cs +++ b/SeaHavenIndustries.Tests/PasswordResetTestHost.cs @@ -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; /// 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 { - ["JWT:Secret"] = new string('k', 64), + ["JWT:Secret"] = JwtSecret, ["JWT:ValidIssuer"] = "issuer", ["JWT:ValidAudience"] = "audience" }); @@ -90,6 +93,7 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable manager.FeatureProviders.Add(new OnlyAuthenticationController()); }); builder.Services.AddPasswordResetRateLimiting(); + builder.Services.AddPasswordResetEmailDelivery(); var app = builder.Build(); app.UseForwardedHeaders(); @@ -141,7 +145,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. /// - public async Task AddSiblingCodeAsync(string email, string userId) + public async Task AddSiblingCodeAsync(string email, string userId, bool keyedHash = true) { const string code = "424242"; const string salt = "0123456789abcdef0123456789abcdef"; @@ -152,7 +156,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(); @@ -169,8 +175,25 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable return request; } - public Task ForgetPasswordAsync(string email, string? clientIp = null) => - Client.SendAsync(Post("api/Authentication/ForgetPassword", new { email }, clientIp)); + /// Requests a code and waits until any queued email has been handed to the sender. + public async Task 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(); + 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 VerifyAsync(string email, string code, string? clientIp = null) => Client.SendAsync(Post("api/Authentication/VerificationCode", new { email, code }, clientIp)); From a6dd40b972c2257cd3f0560702c22330cfb20180 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:12:49 -0300 Subject: [PATCH 05/10] fix(auth): only count real guesses, drop codes that cannot be emailed, trace reset email sends --- .../AuthenticationServiceTests.cs | 21 +++++++++++++ .../SentryPipelineContractTests.cs | 1 + .../PasswordResetEmailDelivery.cs | 31 ++++++++++++++++--- .../Helpers/InMemoryPasswordResetThrottle.cs | 11 +++++++ .../Implementation/AuthenticationService.cs | 14 +++++++-- .../Interfaces/IPasswordResetThrottle.cs | 8 ++++- .../PasswordResetAbuseLimitsTests.cs | 28 +++++++++++++++++ .../PasswordResetTestHost.cs | 1 + 8 files changed, 107 insertions(+), 8 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index 6b539d2..0ac9cde 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -138,6 +138,7 @@ public class AuthenticationServiceTests var registered = new Mock(); var unregistered = new Mock(); var email = new Mock(); + email.Setup(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny())).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); @@ -149,6 +150,26 @@ public class AuthenticationServiceTests email.Verify(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } + [Fact] + public async Task ForgetPassword_EmailThatCannotBeQueued_DropsTheCodeAndDoesNotCountTheRequest() + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + var forget = new Mock(); + var email = new Mock(); + email.SetupSequence(e => e.TryEnqueue(user.Email!, It.IsAny(), It.IsAny())) + .Returns(false).Returns(false).Returns(false).Returns(true); + + var service = NewService(userData, forget, email, out _, out _); + for (var request = 0; request < 4; request++) + await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); + + // Three undelivered codes were removed, and they did not use up the hourly limit of three. + forget.Verify(f => f.RemoveByEmailAsync(user.Email!, It.IsAny()), Times.Exactly(3)); + email.Verify(e => e.TryEnqueue(user.Email!, It.IsAny(), It.IsAny()), Times.Exactly(4)); + } + [Fact] public async Task ForgetPassword_DeletedAccount_IsTreatedAsUnregistered() { diff --git a/Api.SeaHavenIndustries.Tests/SentryPipelineContractTests.cs b/Api.SeaHavenIndustries.Tests/SentryPipelineContractTests.cs index b5ff0d3..90f539b 100644 --- a/Api.SeaHavenIndustries.Tests/SentryPipelineContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/SentryPipelineContractTests.cs @@ -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)); diff --git a/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs b/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs index 348073f..5b2b7af 100644 --- a/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs +++ b/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs @@ -1,5 +1,7 @@ using System.Threading.Channels; +using Api.SeaHavenIndustries.Observability; using SeaHaven.Services.Interfaces; +using Sentry; namespace Api.SeaHavenIndustries.HostedServices { @@ -62,32 +64,43 @@ namespace Api.SeaHavenIndustries.HostedServices private readonly PasswordResetEmailChannel _channel; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; + private readonly IHub _sentryHub; public PasswordResetEmailSenderHostedService( PasswordResetEmailChannel channel, IServiceScopeFactory scopeFactory, - ILogger logger) + ILogger 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 { - await using var scope = _scopeFactory.CreateAsyncScope(); - var sender = scope.ServiceProvider.GetRequiredService(); - if (!await sender.SendEmailAsync(email.EmailTo, email.Subject, email.Body)) - _logger.LogWarning("Password reset email was not accepted by the mail provider."); + await SendAsync(email); + transaction.FinishOk(); + } + 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 { @@ -95,5 +108,13 @@ namespace Api.SeaHavenIndustries.HostedServices } } } + + private async Task SendAsync(PasswordResetEmail email) + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var sender = scope.ServiceProvider.GetRequiredService(); + if (!await sender.SendEmailAsync(email.EmailTo, email.Subject, email.Body)) + _logger.LogWarning("Password reset email was not accepted by the mail provider."); + } } } diff --git a/SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs b/SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs index 00ad7aa..0502f0c 100644 --- a/SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs +++ b/SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs @@ -46,6 +46,17 @@ namespace SeaHaven.Services.Helpers } } + 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(); diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index 94e0833..32cc06c 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -162,7 +162,12 @@ namespace SeaHaven.Services.Implementation if (active) { var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes."; - _resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body); + if (!_resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body)) + { + // The code will never reach the user: drop it and do not count the request. + await _forgetPasswordDataService.RemoveByEmailAsync(user.Email!, cancellationToken); + _resetThrottle.ReleaseCodeRequest(requested); + } } } @@ -219,17 +224,22 @@ namespace SeaHaven.Services.Implementation // 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 only when the code matches. + // given back when the code matches or there is no live code to guess at. if (!_resetThrottle.TryReserveCheck(email)) return null; var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken); if (pending == null) + { + _resetThrottle.ReleaseCheck(email); return null; + } var nowUtc = _timeProvider.GetUtcNow().UtcDateTime; if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken)) { + // Expired or out of attempts: nothing was compared, so no guess is counted. + _resetThrottle.ReleaseCheck(email); await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); return null; } diff --git a/SeaHaven.Services/Interfaces/IPasswordResetThrottle.cs b/SeaHaven.Services/Interfaces/IPasswordResetThrottle.cs index 8cdcc2e..5484994 100644 --- a/SeaHaven.Services/Interfaces/IPasswordResetThrottle.cs +++ b/SeaHaven.Services/Interfaces/IPasswordResetThrottle.cs @@ -12,13 +12,19 @@ namespace SeaHaven.Services.Interfaces /// bool TryAcceptCodeRequest(string email); + /// Gives back an accepted code request whose email could not be queued. + void ReleaseCodeRequest(string email); + /// /// 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. /// bool TryReserveCheck(string email); - /// Gives back the reservation of a check whose code matched. + /// + /// Gives back a reservation that did not become a failed guess: the code matched, + /// or there was no live code to compare it with. + /// void ReleaseCheck(string email); } } diff --git a/SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs b/SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs index 2431cc4..9fd8388 100644 --- a/SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs +++ b/SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs @@ -92,6 +92,34 @@ public sealed class PasswordResetAbuseLimitsTests 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() { diff --git a/SeaHavenIndustries.Tests/PasswordResetTestHost.cs b/SeaHavenIndustries.Tests/PasswordResetTestHost.cs index a16774e..1199080 100644 --- a/SeaHavenIndustries.Tests/PasswordResetTestHost.cs +++ b/SeaHavenIndustries.Tests/PasswordResetTestHost.cs @@ -93,6 +93,7 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable manager.FeatureProviders.Add(new OnlyAuthenticationController()); }); builder.Services.AddPasswordResetRateLimiting(); + builder.Services.AddSingleton(Sentry.Extensibility.HubAdapter.Instance); builder.Services.AddPasswordResetEmailDelivery(); var app = builder.Build(); From 57af8a12068a4bc04e9294da556784c7cde261e3 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:38:11 -0300 Subject: [PATCH 06/10] fix(auth): store an unmatchable code when the reset email cannot be queued, keeping data calls identical --- .../AuthenticationServiceTests.cs | 44 +++++++++++++++++-- .../Implementation/AuthenticationService.cs | 24 +++++----- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index 0ac9cde..2fd6382 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -157,17 +157,53 @@ public class AuthenticationServiceTests var userData = new Mock(); userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); var forget = new Mock(); + var stored = new List<(string Hash, string Salt)>(); + forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, h, s, _, _, _) => stored.Add((h, s))) + .Returns(Task.CompletedTask); var email = new Mock(); - email.SetupSequence(e => e.TryEnqueue(user.Email!, It.IsAny(), It.IsAny())) - .Returns(false).Returns(false).Returns(false).Returns(true); + var bodies = new List(); + // The queue is full for the first three requests. + email.Setup(e => e.TryEnqueue(user.Email!, It.IsAny(), It.IsAny())) + .Returns((_, _, 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); - // Three undelivered codes were removed, and they did not use up the hourly limit of three. - forget.Verify(f => f.RemoveByEmailAsync(user.Email!, It.IsAny()), Times.Exactly(3)); + // The three undelivered requests did not use up the hourly limit of three. email.Verify(e => e.TryEnqueue(user.Email!, It.IsAny(), It.IsAny()), Times.Exactly(4)); + stored.Should().HaveCount(4); + for (var request = 0; request < 3; 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().BeFalse(); + } + var delivered = System.Text.RegularExpressions.Regex.Match(bodies[3], @"Your Password Reset Code is: (\d{6})").Groups[1].Value; + PasswordResetCodeSecrets.Matches(ResetKey, stored[3].Salt, delivered, stored[3].Hash).Should().BeTrue(); + forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ForgetPassword_EmailThatCannotBeQueued_MakesTheSameDataCallsAsAnUnknownEmail() + { + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(IdentityTestHelpers.User()); + var registered = new Mock(); + var unregistered = new Mock(); + var email = new Mock(); + email.Setup(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny())).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] diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index 32cc06c..f07ff28 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -150,25 +150,25 @@ namespace SeaHaven.Services.Implementation var hash = PasswordResetCodeSecrets.Hash(ResetCodeKey, salt, code); var active = user != null && user.IsDeleted != true && !string.IsNullOrWhiteSpace(user.Email); + var queued = false; + if (active) + { + var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes."; + queued = _resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body); + + // A code that cannot be emailed is stored unmatchable and the request is not counted. + if (!queued) + _resetThrottle.ReleaseCodeRequest(requested); + } + await _forgetPasswordDataService.ReplaceCodeAsync( active ? user!.Email! : requested, active ? user!.Id : string.Empty, - active ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(), + queued ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(), salt, nowUtc.Add(ResetCodeLifetime), nowUtc, cancellationToken); - - if (active) - { - var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes."; - if (!_resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body)) - { - // The code will never reach the user: drop it and do not count the request. - await _forgetPasswordDataService.RemoveByEmailAsync(user.Email!, cancellationToken); - _resetThrottle.ReleaseCodeRequest(requested); - } - } } public async Task VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken) From ea1370847fb442583e42e2609f992b7976da78ce Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:46:37 -0300 Subject: [PATCH 07/10] test(auth): move the password-policy reset case onto hashed, attempt-counted codes --- .../PasswordPolicyTests.cs | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs index b33d466..8183cf3 100644 --- a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs +++ b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs @@ -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(), Mock.Of(), - Mock.Of()); + Mock.Of(), + 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(); - forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny())) - .ReturnsAsync(true); + var salt = PasswordResetCodeSecrets.NewSalt(); forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny())) - .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(), It.IsAny(), It.IsAny())) + .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(), It.IsAny()), Times.Never); + forget.Verify(f => f.RefundAttemptAsync(7, It.IsAny()), Times.Once); } private async Task> 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(), (forget ?? new Mock()).Object, - Mock.Of()); + Mock.Of(), + TimeProvider.System); public async ValueTask DisposeAsync() { From fe5f27c8e1ac9aa11d47f9d1f2e83ebeb35d396d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 14:08:30 -0300 Subject: [PATCH 08/10] test(auth): build the password-policy service with the reset queue, throttle and keyed hash --- Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs index 8183cf3..6e3a85e 100644 --- a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs +++ b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs @@ -133,7 +133,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }), Mock.Of(), Mock.Of(), - Mock.Of(), + Mock.Of(), + new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System); var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None); @@ -179,7 +180,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(), It.IsAny(), It.IsAny())) @@ -220,7 +221,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }), Mock.Of(), (forget ?? new Mock()).Object, - Mock.Of(), + Mock.Of(), + new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System); public async ValueTask DisposeAsync() From 900e141bda772b1c573d310cdac81f7f4c3deada Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 16:42:03 -0300 Subject: [PATCH 09/10] fix(auth): delete only the checked code and older ones, so a code issued concurrently survives --- .../AuthenticationServiceTests.cs | 2 +- .../ForgetPasswordDataService.cs | 8 ++ .../Interfaces/IForgetPasswordDataService.cs | 6 + .../Implementation/AuthenticationService.cs | 8 +- .../PasswordResetRaceTests.cs | 118 ++++++++++++++++++ .../PasswordResetTestHost.cs | 3 +- 6 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 SeaHavenIndustries.Tests/PasswordResetRaceTests.cs diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index ef8a297..88d68fb 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -206,7 +206,7 @@ public class AuthenticationServiceTests var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None); result.Should().BeFalse(); - forget.Verify(f => f.RemoveByEmailAsync("a@b.com", It.IsAny()), Times.Once); + forget.Verify(f => f.RemoveIssuedThroughAsync("a@b.com", 7, It.IsAny()), Times.Once); store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); } diff --git a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs index 7e3b3c4..fe3bd1d 100644 --- a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs +++ b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs @@ -75,6 +75,14 @@ namespace SeaHaven.DataServices.Implementation .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(); } } diff --git a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs index a7de61e..1c7f0ad 100644 --- a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs @@ -20,5 +20,11 @@ namespace SeaHaven.DataServices.Interfaces Task RefundAttemptAsync(int id, CancellationToken cancellationToken); Task RemoveByEmailAsync(string email, CancellationToken cancellationToken); + + /// + /// Deletes the email's codes issued up to and including , + /// leaving any code issued after it by a concurrent request. + /// + Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index b168f9a..0cd58cf 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -178,7 +178,7 @@ namespace SeaHaven.Services.Implementation var user = await _userManager.FindByIdAsync(pending.UserId); if (user == null || user.IsDeleted == true) { - await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); + await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken); return false; } @@ -211,9 +211,11 @@ namespace SeaHaven.Services.Implementation 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.RemoveByEmailAsync(pending.Email, cancellationToken); + await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken); return null; } @@ -221,7 +223,7 @@ namespace SeaHaven.Services.Implementation return pending; if (pending.FailedAttempts + 1 >= MaxCodeAttempts) - await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); + await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken); return null; } diff --git a/SeaHavenIndustries.Tests/PasswordResetRaceTests.cs b/SeaHavenIndustries.Tests/PasswordResetRaceTests.cs new file mode 100644 index 0000000..20a89f2 --- /dev/null +++ b/SeaHavenIndustries.Tests/PasswordResetRaceTests.cs @@ -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; + +/// +/// 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. +/// +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? _beforeNextConsume; + private Func? _afterNextConsume; + + public Func? BeforeNextConsume { set => _beforeNextConsume = value; } + public Func? AfterNextConsume { set => _afterNextConsume = value; } + + public void Register(IServiceCollection services) => + services.Replace(ServiceDescriptor.Scoped(provider => + new RacingForgetPasswordDataService(new ForgetPasswordDataService(provider.GetRequiredService()), 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 GetByEmailAsync(string email, CancellationToken cancellationToken) => + _inner.GetByEmailAsync(email, cancellationToken); + + public async Task 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); + } +} diff --git a/SeaHavenIndustries.Tests/PasswordResetTestHost.cs b/SeaHavenIndustries.Tests/PasswordResetTestHost.cs index 6904954..e44f2df 100644 --- a/SeaHavenIndustries.Tests/PasswordResetTestHost.cs +++ b/SeaHavenIndustries.Tests/PasswordResetTestHost.cs @@ -48,7 +48,7 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable public CapturingLoggerProvider Logged { get; private init; } = null!; public IServiceProvider Services => _app.Services; - public static async Task StartAsync(IEmailSender? emailSender = null) + public static async Task StartAsync(IEmailSender? emailSender = null, Action? configureServices = null) { var databasePath = Path.Combine(Path.GetTempPath(), $"password-reset-{Guid.NewGuid():N}.db"); var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString(); @@ -81,6 +81,7 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable builder.Services.AddSingleton(time); builder.Services.AddSingleton(emailSender ?? sent); builder.Services.AddDataServices(); + configureServices?.Invoke(builder.Services); builder.Services.AddBusinessServices(builder.Configuration); builder.Services.AddControllers() .AddApplicationPart(typeof(AuthenticationController).Assembly) From f0dcba63fde8637d67cae33ec4efff342fdec111 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 17:38:51 -0300 Subject: [PATCH 10/10] fix(auth): give back reset check and request slots when a data call fails or is cancelled --- .../AuthenticationServiceTests.cs | 78 +++++++++++++++++ .../Implementation/AuthenticationService.cs | 87 +++++++++++-------- 2 files changed, 128 insertions(+), 37 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index 4f6a138..e64014f 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -271,6 +271,84 @@ public class AuthenticationServiceTests store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), 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(); + var lookups = 0; + forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny())) + .Returns(() => ++lookups <= InMemoryPasswordResetThrottle.FailedChecksPerDay + ? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")) + : Task.FromResult(pending)); + forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny(), It.IsAny())).ReturnsAsync(true); + var service = NewService(new Mock(), forget, new Mock(), out _, out _); + + for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++) + { + var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + (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(); + forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny())).ReturnsAsync(pending); + var consumes = 0; + forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny(), It.IsAny())) + .Returns(() => ++consumes <= InMemoryPasswordResetThrottle.FailedChecksPerDay + ? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")) + : Task.FromResult(true)); + var service = NewService(new Mock(), forget, new Mock(), out _, out _); + + for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++) + { + var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + (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(); + userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + var forget = new Mock(); + var writes = 0; + forget.Setup(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => ++writes <= InMemoryPasswordResetThrottle.CodeRequestsPerHour + ? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")) + : Task.CompletedTask); + var email = new Mock(); + email.Setup(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny())).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(); + } + + await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); + + writes.Should().Be(InMemoryPasswordResetThrottle.CodeRequestsPerHour + 1); + forget.Verify(f => f.PurgeExpiredAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Theory] [InlineData("123456", "123456", true)] [InlineData("123456", " 123456 ", true)] diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index c00c3c2..73b43d2 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -164,25 +164,34 @@ namespace SeaHaven.Services.Implementation var hash = PasswordResetCodeSecrets.Hash(ResetCodeKey, salt, code); var active = user != null && user.IsDeleted != true && !string.IsNullOrWhiteSpace(user.Email); - var queued = false; - if (active) + // The request stays counted only once its row is stored. A code that cannot be + // emailed is stored unmatchable and not counted, and a failed or cancelled + // write is not counted either. + var counted = false; + try { - var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes."; - queued = _resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body); + var queued = false; + if (active) + { + var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes."; + queued = _resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body); + } - // A code that cannot be emailed is stored unmatchable and the request is not counted. - if (!queued) + await _forgetPasswordDataService.ReplaceCodeAsync( + active ? user!.Email! : requested, + active ? user!.Id : string.Empty, + queued ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(), + salt, + nowUtc.Add(ResetCodeLifetime), + nowUtc, + cancellationToken); + counted = queued || !active; + } + finally + { + if (!counted) _resetThrottle.ReleaseCodeRequest(requested); } - - await _forgetPasswordDataService.ReplaceCodeAsync( - active ? user!.Email! : requested, - active ? user!.Id : string.Empty, - queued ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(), - salt, - nowUtc.Add(ResetCodeLifetime), - nowUtc, - cancellationToken); } public async Task VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken) @@ -242,33 +251,37 @@ namespace SeaHaven.Services.Implementation if (!_resetThrottle.TryReserveCheck(email)) return null; - var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken); - if (pending == 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 { - _resetThrottle.ReleaseCheck(email); + 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(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; } - - 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)) + finally { - // Expired or out of attempts: nothing was compared, so no guess is counted. - _resetThrottle.ReleaseCheck(email); - await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken); - return null; + if (!wrongGuess) + _resetThrottle.ReleaseCheck(email); } - - if (PasswordResetCodeSecrets.Matches(ResetCodeKey, pending.CodeSalt, code, pending.CodeHash)) - { - _resetThrottle.ReleaseCheck(email); - return pending; - } - - if (pending.FailedAttempts + 1 >= MaxCodeAttempts) - await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken); - return null; } private JwtSecurityToken GetToken(List authClaims)