From 66a49ab9579f1bb86b1777dda80d397f7060f473 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:04:33 -0300 Subject: [PATCH 1/2] feat(auth): enforce one password policy on every password-setting path (SH-386) Both hosts now apply the same Identity password rule: at least 6 characters with one uppercase letter, one number and one special character. Change password requires an authenticated caller, verifies the current password before evaluating the new one, and reports a policy rejection separately from a wrong current password. --- .../AuthenticationControllerTests.cs | 38 +++- .../PasswordPolicyTests.cs | 180 ++++++++++++++++++ .../Controllers/AuthenticationController.cs | 18 +- .../Infrastructure/IdentityRegistration.cs | 24 +++ Api.SeaHavenIndustries/Program.cs | 8 +- .../Auth/IdentityPasswordPolicy.cs | 27 +++ SeaHaven.Services/DTOs/IdentityDTOs.cs | 12 ++ .../Implementation/AuthenticationService.cs | 21 +- .../Interfaces/IAuthenticationService.cs | 2 +- SeaHavenIndustries/Program.cs | 9 +- 10 files changed, 311 insertions(+), 28 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs create mode 100644 Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs create mode 100644 Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs index 99d5ab8..4187a24 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs @@ -117,11 +117,11 @@ public class AuthenticationControllerTests { var service = new Mock(); service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny())) - .ReturnsAsync(true); + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Succeeded }); var controller = NewController(service, "42"); - var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None); + var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Newpassword = "new", Confirmpassword = "new" }, CancellationToken.None); var ok = result.Should().BeOfType().Subject; var response = ok.Value.Should().BeOfType().Subject; @@ -130,11 +130,11 @@ public class AuthenticationControllerTests } [Fact] - public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus() + public async Task ChangePassword_WrongCurrentPassword_ReturnsOldPasswordIncorrectStatus() { var service = new Mock(); service.Setup(s => s.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(false); + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.CurrentPasswordIncorrect }); var controller = NewController(service, "42"); @@ -143,6 +143,36 @@ public class AuthenticationControllerTests var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; response.Status.Should().Be("Old Password is incorrect"); + response.Message.Should().Be("Current password is incorrect"); + } + + [Fact] + public async Task ChangePassword_PolicyRejection_ReturnsPasswordRequirementsMessage() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "weak", It.IsAny())) + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.PasswordRejected }); + + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword( + new ChangePasswords { Currentpassword = "Current1!", Newpassword = "weak", Confirmpassword = "weak" }, + CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Password does not meet requirements"); + response.Message.Should().Be( + "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."); + } + + [Fact] + public void ChangePassword_RequiresAuthenticatedCaller() + { + var method = typeof(AuthenticationController).GetMethod(nameof(AuthenticationController.ChangePassword))!; + + method.GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: true) + .Should().NotBeEmpty(); } [Fact] diff --git a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs new file mode 100644 index 0000000..946a24d --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs @@ -0,0 +1,180 @@ +using Api.SeaHavenIndustries.Infrastructure; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// Exercises the password rule through the same Identity registration the API +/// host uses, so these assertions describe the rule that runs in production. +/// +public sealed class PasswordPolicyTests : IAsyncDisposable +{ + private const string CurrentPassword = "Current1!"; + private readonly ServiceProvider _provider; + private readonly AsyncServiceScope _scope; + + public PasswordPolicyTests() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddSeaHavenIdentity(); + _provider = services.BuildServiceProvider(); + _scope = _provider.CreateAsyncScope(); + } + + private UserManager UserManager => + _scope.ServiceProvider.GetRequiredService>(); + + [Theory] + [InlineData("Ab1!x", "PasswordTooShort")] + [InlineData("abc12!", "PasswordRequiresUpper")] + [InlineData("Abcde!", "PasswordRequiresDigit")] + [InlineData("Abcde1", "PasswordRequiresNonAlphanumeric")] + public async Task Policy_RejectsPasswordMissingOneRule(string password, string expectedCode) + { + var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" }; + + var errors = await ValidateAsync(user, password); + + errors.Select(error => error.Code).Should().Equal(expectedCode); + } + + [Theory] + [InlineData("Abc1!x")] + [InlineData("ABC12!")] + public async Task Policy_AcceptsSixCharacterPasswordMeetingEveryRule(string password) + { + var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" }; + + var errors = await ValidateAsync(user, password); + + errors.Should().BeEmpty(); + } + + [Fact] + public void Registration_AppliesSharedPolicyOptions() + { + var options = _scope.ServiceProvider.GetRequiredService>().Value.Password; + + options.RequiredLength.Should().Be(6); + options.RequireUppercase.Should().BeTrue(); + options.RequireDigit.Should().BeTrue(); + options.RequireNonAlphanumeric.Should().BeTrue(); + options.RequireLowercase.Should().BeFalse(); + } + + [Fact] + public async Task ChangePassword_WrongCurrentPassword_IsRejectedBeforeNewPasswordIsEvaluated() + { + var user = await CreateUserAsync(); + var service = NewAuthenticationService(); + + var result = await service.ChangePasswordAsync(user.Id, "Wrong1!", "weak", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.CurrentPasswordIncorrect); + (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); + } + + [Fact] + public async Task ChangePassword_CorrectCurrentPasswordAndWeakNewPassword_IsRejectedByPolicy() + { + var user = await CreateUserAsync(); + var service = NewAuthenticationService(); + + var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "abcdef", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.PasswordRejected); + (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); + } + + [Fact] + public async Task ChangePassword_CorrectCurrentPasswordAndCompliantNewPassword_ChangesPassword() + { + var user = await CreateUserAsync(); + var service = NewAuthenticationService(); + + var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.Succeeded); + var reloaded = await UserManager.FindByIdAsync(user.Id); + (await UserManager.CheckPasswordAsync(reloaded!, "Next2@x")).Should().BeTrue(); + } + + [Fact] + public async Task ChangePassword_CancelledToken_Throws() + { + var service = NewAuthenticationService(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var act = () => service.ChangePasswordAsync("any", CurrentPassword, "Next2@x", cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode() + { + var user = await CreateUserAsync(); + var forget = new Mock(); + forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny())) + .ReturnsAsync(true); + forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny())) + .ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" }); + var service = NewAuthenticationService(forget); + + var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None); + + reset.Should().BeFalse(); + (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); + forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private async Task> ValidateAsync(ApplicationUser user, string password) + { + var errors = new List(); + foreach (var validator in UserManager.PasswordValidators) + { + var result = await validator.ValidateAsync(UserManager, user, password); + errors.AddRange(result.Errors); + } + + return errors; + } + + private async Task CreateUserAsync() + { + var user = new ApplicationUser { UserName = "member@example.com", Email = "member@example.com" }; + var created = await UserManager.CreateAsync(user, CurrentPassword); + created.Succeeded.Should().BeTrue(); + return user; + } + + private AuthenticationService NewAuthenticationService(Mock? forget = null) => + new( + UserManager, + Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }), + Mock.Of(), + (forget ?? new Mock()).Object, + Mock.Of()); + + public async ValueTask DisposeAsync() + { + await _scope.DisposeAsync(); + await _provider.DisposeAsync(); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 12fd8fa..7b6569a 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -55,20 +55,26 @@ namespace Api.SeaHavenIndustries.Controllers } + [Authorize] [Route("ChangePassword")] [HttpPost] public async Task ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken) { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken); - if (succeeded) + var result = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Newpassword, cancellationToken); + return result.Status switch { - return Ok(new Response { Status = "Success ", Message = "Password successfully changed" }); - } - else - return BadRequest(new Response { Status = "Old Password is incorrect" }); + ChangePasswordStatus.Succeeded => + Ok(new Response { Status = "Success ", Message = "Password successfully changed" }), + ChangePasswordStatus.PasswordRejected => + BadRequest(new Response { Status = "Password does not meet requirements", Message = PasswordRequirementsMessage }), + _ => BadRequest(new Response { Status = "Old Password is incorrect", Message = "Current password is incorrect" }) + }; } + private const string PasswordRequirementsMessage = + "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."; + [HttpPost] [Route("UpdateProfile")] public async Task UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken) diff --git a/Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs b/Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs new file mode 100644 index 0000000..752e494 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/IdentityRegistration.cs @@ -0,0 +1,24 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; + +namespace Api.SeaHavenIndustries.Infrastructure +{ + public static class IdentityRegistration + { + /// + /// Registers ASP.NET Identity for the API with the shared password policy. + /// Program.cs and the behavior tests both compose Identity through this + /// method so the rule under test is the rule that runs. + /// + public static IdentityBuilder AddSeaHavenIdentity(this IServiceCollection services) + { + return services.AddIdentity(options => + { + options.User.RequireUniqueEmail = false; + IdentityPasswordPolicy.Apply(options.Password); + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6baf11f..0151543 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; @@ -43,12 +44,7 @@ ConfigurationManager configuration = builder.Configuration; builder.Services.AddDbContext(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); -builder.Services.AddIdentity(options => -{ - options.User.RequireUniqueEmail = false; -}) - .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); +builder.Services.AddSeaHavenIdentity(); builder.Services.AddControllers(options => { diff --git a/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs b/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs new file mode 100644 index 0000000..a0161ff --- /dev/null +++ b/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Identity; + +namespace Data.SeaHavenIndustries +{ + /// + /// The single password rule for every surface that sets a password: at least + /// six characters with one uppercase letter, one number, and one special + /// character. Lowercase letters are deliberately not required so the server + /// accepts exactly what the four-item checklist in the web app marks as met. + /// + public static class IdentityPasswordPolicy + { + public const int MinimumLength = 6; + + public static void Apply(PasswordOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + options.RequiredLength = MinimumLength; + options.RequireUppercase = true; + options.RequireDigit = true; + options.RequireNonAlphanumeric = true; + options.RequireLowercase = false; + options.RequiredUniqueChars = 1; + } + } +} diff --git a/SeaHaven.Services/DTOs/IdentityDTOs.cs b/SeaHaven.Services/DTOs/IdentityDTOs.cs index ad3b8cb..e959478 100644 --- a/SeaHaven.Services/DTOs/IdentityDTOs.cs +++ b/SeaHaven.Services/DTOs/IdentityDTOs.cs @@ -11,6 +11,18 @@ namespace SeaHaven.Services.DTOs public string Id { get; set; } = string.Empty; } + public enum ChangePasswordStatus + { + Succeeded, + CurrentPasswordIncorrect, + PasswordRejected + } + + public sealed class ChangePasswordResultDTO + { + public ChangePasswordStatus Status { get; init; } + } + public class UpdateProfileRequestDTO { public string? Name { get; set; } diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index d86f19a..e293276 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -80,16 +80,27 @@ namespace SeaHaven.Services.Implementation return null; } - public async Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken) + public async Task ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var user = await _userManager.FindByIdAsync(userId); - if (user == null) - return false; + if (user == null || user.IsDeleted == true) + return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect); - var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", confirmPassword ?? ""); - return result.Succeeded; + // The current password is verified before the new one is evaluated, so a + // caller without it learns nothing about the policy outcome. + if (!await _userManager.CheckPasswordAsync(user, currentPassword ?? "")) + return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect); + + var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", newPassword ?? ""); + return ChangePasswordResult(result.Succeeded + ? ChangePasswordStatus.Succeeded + : ChangePasswordStatus.PasswordRejected); } + private static ChangePasswordResultDTO ChangePasswordResult(ChangePasswordStatus status) => + new() { Status = status }; + public async Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken) { var exists = await _userDataService.UpdateProfileAsync( diff --git a/SeaHaven.Services/Interfaces/IAuthenticationService.cs b/SeaHaven.Services/Interfaces/IAuthenticationService.cs index 426eb5f..e4ffc9b 100644 --- a/SeaHaven.Services/Interfaces/IAuthenticationService.cs +++ b/SeaHaven.Services/Interfaces/IAuthenticationService.cs @@ -5,7 +5,7 @@ namespace SeaHaven.Services.Interfaces public interface IAuthenticationService { Task LoginAsync(string? username, string? password, CancellationToken cancellationToken); - Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken); + Task ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken); Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken); Task ForgetPasswordAsync(string email, CancellationToken cancellationToken); Task VerifyCodeAsync(string code, CancellationToken cancellationToken); diff --git a/SeaHavenIndustries/Program.cs b/SeaHavenIndustries/Program.cs index 48b76c3..5a33b68 100644 --- a/SeaHavenIndustries/Program.cs +++ b/SeaHavenIndustries/Program.cs @@ -31,7 +31,8 @@ builder.Services.AddAuthentication(options => .AddIdentityCookies(); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); -builder.Services.AddDbContext(options => { +builder.Services.AddDbContext(options => +{ options.UseSqlServer(connectionString); }, ServiceLifetime.Transient); builder.Services.AddDatabaseDeveloperPageExceptionFilter(); @@ -39,11 +40,7 @@ builder.Services.AddDatabaseDeveloperPageExceptionFilter(); builder.Services.AddIdentityCore(options => { options.SignIn.RequireConfirmedAccount = true; - options.Password.RequireDigit = true; - options.Password.RequireLowercase = false; - options.Password.RequireUppercase = false; - options.Password.RequireNonAlphanumeric = true; - options.Password.RequiredLength = 8; + IdentityPasswordPolicy.Apply(options.Password); }).AddRoles().AddEntityFrameworkStores().AddSignInManager() .AddDefaultTokenProviders(); From 9091335ff23e6a32c0647c99b67f2fdd2c66f05d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:02:58 -0300 Subject: [PATCH 2/2] fix(auth): reject an unconfirmed new password and report only policy failures as weak --- .../AuthenticationControllerTests.cs | 34 +++++++++++++++++ .../PasswordPolicyTests.cs | 38 +++++++++++++++++++ .../Controllers/AuthenticationController.cs | 7 ++++ .../Auth/IdentityPasswordPolicy.cs | 20 ++++++++++ SeaHaven.Services/DTOs/IdentityDTOs.cs | 4 +- .../Implementation/AuthenticationService.cs | 9 +++-- 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs index 4187a24..0eaa5b4 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs @@ -166,6 +166,40 @@ public class AuthenticationControllerTests "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."); } + [Fact] + public async Task ChangePassword_ConfirmationMismatch_IsRejectedWithoutChangingThePassword() + { + var service = new Mock(); + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword( + new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@y" }, + CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + bad.Value.Should().BeOfType().Subject.Message.Should().Be("Passwords don't match"); + service.Verify( + s => s.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ChangePassword_NonPolicyFailure_ReturnsTheGenericMessage() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "Next2@x", It.IsAny())) + .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Failed }); + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword( + new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@x" }, + CancellationToken.None); + + var response = result.Should().BeOfType().Subject.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Your password could not be changed. Try again."); + response.Message.Should().NotContain("at least 6 characters"); + } + [Fact] public void ChangePassword_RequiresAuthenticatedCaller() { diff --git a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs index 946a24d..b33d466 100644 --- a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs +++ b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs @@ -114,6 +114,44 @@ public sealed class PasswordPolicyTests : IAsyncDisposable (await UserManager.CheckPasswordAsync(reloaded!, "Next2@x")).Should().BeTrue(); } + [Theory] + [InlineData("ConcurrencyFailure")] + [InlineData("PasswordMismatch")] + [InlineData("DefaultError")] + public async Task ChangePassword_NonPolicyIdentityFailure_IsNotReportedAsAWeakPassword(string code) + { + var user = new ApplicationUser { Id = "member-1", UserName = "member@example.com" }; + var userManager = new Mock>( + Mock.Of>(), null!, null!, null!, null!, null!, null!, null!, null!); + userManager.Setup(m => m.FindByIdAsync(user.Id)).ReturnsAsync(user); + userManager.Setup(m => m.CheckPasswordAsync(user, CurrentPassword)).ReturnsAsync(true); + userManager.Setup(m => m.ChangePasswordAsync(user, CurrentPassword, "Next2@x")) + .ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = code, Description = "failed" })); + var service = new AuthenticationService( + userManager.Object, + Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }), + Mock.Of(), + Mock.Of(), + Mock.Of()); + + var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None); + + result.Status.Should().Be(ChangePasswordStatus.Failed); + } + + [Theory] + [InlineData("PasswordTooShort", true)] + [InlineData("PasswordRequiresUpper", true)] + [InlineData("PasswordRequiresDigit", true)] + [InlineData("PasswordRequiresNonAlphanumeric", true)] + [InlineData("ConcurrencyFailure", false)] + [InlineData("PasswordMismatch", false)] + public void IsPolicyRejection_MatchesOnlyThePasswordRuleCodes(string code, bool expected) + { + IdentityPasswordPolicy.IsPolicyRejection(IdentityResult.Failed(new IdentityError { Code = code })) + .Should().Be(expected); + } + [Fact] public async Task ChangePassword_CancelledToken_Throws() { diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 7b6569a..dcd4dfd 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -60,6 +60,11 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost] public async Task ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken) { + // [Compare] already rejects this during model validation; the check here keeps the + // unconfirmed password from ever being set if that validation is bypassed. + if (!string.Equals(usermodel.Newpassword, usermodel.Confirmpassword, StringComparison.Ordinal)) + return BadRequest(new Response { Status = "Password confirmation does not match", Message = "Passwords don't match" }); + var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; var result = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Newpassword, cancellationToken); return result.Status switch @@ -68,6 +73,8 @@ namespace Api.SeaHavenIndustries.Controllers Ok(new Response { Status = "Success ", Message = "Password successfully changed" }), ChangePasswordStatus.PasswordRejected => BadRequest(new Response { Status = "Password does not meet requirements", Message = PasswordRequirementsMessage }), + ChangePasswordStatus.Failed => + BadRequest(new Response { Status = "Password not changed", Message = "Your password could not be changed. Try again." }), _ => BadRequest(new Response { Status = "Old Password is incorrect", Message = "Current password is incorrect" }) }; } diff --git a/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs b/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs index a0161ff..50e7543 100644 --- a/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs +++ b/Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs @@ -23,5 +23,25 @@ namespace Data.SeaHavenIndustries options.RequireLowercase = false; options.RequiredUniqueChars = 1; } + + private static readonly HashSet PolicyErrorCodes = new(StringComparer.Ordinal) + { + nameof(IdentityErrorDescriber.PasswordTooShort), + nameof(IdentityErrorDescriber.PasswordRequiresUpper), + nameof(IdentityErrorDescriber.PasswordRequiresLower), + nameof(IdentityErrorDescriber.PasswordRequiresDigit), + nameof(IdentityErrorDescriber.PasswordRequiresNonAlphanumeric), + nameof(IdentityErrorDescriber.PasswordRequiresUniqueChars) + }; + + /// + /// True when Identity refused the password itself. Other failures, such as a + /// concurrency conflict, must not be reported to the user as a weak password. + /// + public static bool IsPolicyRejection(IdentityResult result) + { + ArgumentNullException.ThrowIfNull(result); + return result.Errors.Any(error => PolicyErrorCodes.Contains(error.Code)); + } } } diff --git a/SeaHaven.Services/DTOs/IdentityDTOs.cs b/SeaHaven.Services/DTOs/IdentityDTOs.cs index e959478..c618500 100644 --- a/SeaHaven.Services/DTOs/IdentityDTOs.cs +++ b/SeaHaven.Services/DTOs/IdentityDTOs.cs @@ -15,7 +15,9 @@ namespace SeaHaven.Services.DTOs { Succeeded, CurrentPasswordIncorrect, - PasswordRejected + PasswordRejected, + /// Identity failed for a reason other than the password policy. + Failed } public sealed class ChangePasswordResultDTO diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index e293276..2a44325 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -93,9 +93,12 @@ namespace SeaHaven.Services.Implementation return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect); var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", newPassword ?? ""); - return ChangePasswordResult(result.Succeeded - ? ChangePasswordStatus.Succeeded - : ChangePasswordStatus.PasswordRejected); + if (result.Succeeded) + return ChangePasswordResult(ChangePasswordStatus.Succeeded); + + return ChangePasswordResult(IdentityPasswordPolicy.IsPolicyRejection(result) + ? ChangePasswordStatus.PasswordRejected + : ChangePasswordStatus.Failed); } private static ChangePasswordResultDTO ChangePasswordResult(ChangePasswordStatus status) =>