From f4dc5312d6492ef849e75cfd5bacfd96bac15218 Mon Sep 17 00:00:00 2001 From: Roman Kholod Date: Tue, 3 Mar 2026 21:13:56 +0200 Subject: [PATCH 1/4] Initial implementation of change password feature --- .../DTO/Auth/ChangePasswordRequestDTO.cs | 15 +++ .../ChangePassword/ChangePasswordCommand.cs | 13 ++ .../ChangePasswordCommandValidator.cs | 18 +++ .../ChangePassword/ChangePasswordHandler.cs | 40 ++++++ .../ChangePasswordRequestDTOValidator.cs | 27 ++++ .../Controllers/AuthController.cs | 14 ++ .../ChangePasswordHandlerTests.cs | 100 +++++++++++++++ .../ChangePasswordRequestDTOValidatorTests.cs | 121 ++++++++++++++++++ 8 files changed, 348 insertions(+) create mode 100644 Streetcode/Streetcode.Auth.BLL/DTO/Auth/ChangePasswordRequestDTO.cs create mode 100644 Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs create mode 100644 Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs create mode 100644 Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs create mode 100644 Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordRequestDTOValidator.cs create mode 100644 Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs create mode 100644 Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordRequestDTOValidatorTests.cs diff --git a/Streetcode/Streetcode.Auth.BLL/DTO/Auth/ChangePasswordRequestDTO.cs b/Streetcode/Streetcode.Auth.BLL/DTO/Auth/ChangePasswordRequestDTO.cs new file mode 100644 index 0000000..1a509af --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/DTO/Auth/ChangePasswordRequestDTO.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Streetcode.Auth.BLL.DTO.Auth +{ + public class ChangePasswordRequestDTO + { + public string Email { get; set; } + public string CurrentPassword { get; set; } + public string NewPassword { get; set; } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000..4c31f7e --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentResults; +using MediatR; +using Streetcode.Auth.BLL.DTO.Auth; + +namespace Streetcode.Auth.BLL.MediatR.ChangePassword +{ + public record ChangePasswordCommand(ChangePasswordRequestDTO Request) : IRequest>; +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs new file mode 100644 index 0000000..2afeeaf --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentValidation; + +namespace Streetcode.Auth.BLL.MediatR.ChangePassword +{ + public class ChangePasswordCommandValidator : AbstractValidator + { + public ChangePasswordCommandValidator() + { + RuleFor(x => x.Request) + .SetValidator(new ChangePasswordRequestDTOValidator()); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs new file mode 100644 index 0000000..7fc359b --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs @@ -0,0 +1,40 @@ +using FluentResults; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Streetcode.Auth.DAL.Entities; + +namespace Streetcode.Auth.BLL.MediatR.ChangePassword +{ + public class ChangePasswordHandler : IRequestHandler> + { + private readonly UserManager _userManager; + + public ChangePasswordHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task> Handle(ChangePasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByEmailAsync(request.Request.Email); + + if (user == null) + { + return Result.Fail("User not found"); + } + + var result = await _userManager.ChangePasswordAsync( + user, + request.Request.CurrentPassword, + request.Request.NewPassword); + + if (!result.Succeeded) + { + var errors = result.Errors.Select(e => e.Description); + return Result.Fail(string.Join(", ", errors)); + } + + return Result.Ok(Unit.Value); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordRequestDTOValidator.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordRequestDTOValidator.cs new file mode 100644 index 0000000..6c842b7 --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordRequestDTOValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Streetcode.Auth.BLL.DTO.Auth; + +namespace Streetcode.Auth.BLL.MediatR.ChangePassword +{ + public class ChangePasswordRequestDTOValidator : AbstractValidator + { + public ChangePasswordRequestDTOValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required.") + .EmailAddress().WithMessage("Invalid email address format."); + + RuleFor(x => x.CurrentPassword) + .NotEmpty().WithMessage("Password is required."); + + RuleFor(x => x.NewPassword) + .NotEmpty().WithMessage("Password is required.") + .MinimumLength(6).WithMessage("Password must be at least 6 characters long.") + .Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter.") + .Matches("[a-z]").WithMessage("Password must contain at least one lowercase letter.") + .Matches("[0-9]").WithMessage("Password must contain at least one number.") + .Matches("[^a-zA-Z0-9]").WithMessage("Password must contain at least one special character.") + .NotEqual(x => x.CurrentPassword).WithMessage("New password cannot be the same as the current password."); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs b/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs index 5798ccd..97254a6 100644 --- a/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs +++ b/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs @@ -1,6 +1,7 @@ using MediatR; using Microsoft.AspNetCore.Mvc; using Streetcode.Auth.BLL.DTO.Auth; +using Streetcode.Auth.BLL.MediatR.ChangePassword; using Streetcode.Auth.BLL.MediatR.Login; using Streetcode.Auth.BLL.MediatR.Logout; using Streetcode.Auth.BLL.MediatR.RefreshToken; @@ -89,5 +90,18 @@ public async Task Logout() return Ok(new { message = "Logged out" }); } + + [HttpPost("change-password")] + public async Task ChangePassword([FromBody] ChangePasswordRequestDTO request) + { + var result = await _mediator.Send(new ChangePasswordCommand(request)); + + if (result.IsSuccess) + { + return Ok("Password changed successfully"); + } + + return BadRequest(result.Errors[0].Message); + } } } diff --git a/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs b/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs new file mode 100644 index 0000000..3fa6055 --- /dev/null +++ b/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs @@ -0,0 +1,100 @@ +using FluentAssertions; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Moq; +using Streetcode.Auth.BLL.DTO.Auth; +using Streetcode.Auth.BLL.MediatR.ChangePassword; +using Streetcode.Auth.DAL.Entities; + +namespace Streetcode.Auth.XUnitTest.ChangePassword +{ + public class ChangePasswordHandlerTests + { + private const string Email = "user@example.com"; + private const string CurrentPassword = "OldPassword123!"; + private const string NewPassword = "NewPassword456!"; + + private readonly Mock> userManagerMock; + private readonly ChangePasswordHandler handler; + + public ChangePasswordHandlerTests() + { + this.userManagerMock = this.CreateUserManagerMock(); + this.handler = new ChangePasswordHandler(this.userManagerMock.Object); + } + + [Fact] + public async Task Handle_ShouldReturnSuccess_WhenCredentialsAreValid() + { + // Arrange + var user = new ApplicationUser { Email = Email }; + this.userManagerMock.Setup(m => m.FindByEmailAsync(Email)).ReturnsAsync(user); + + this.userManagerMock.Setup(m => m.ChangePasswordAsync(user, CurrentPassword, NewPassword)) + .ReturnsAsync(IdentityResult.Success); + + var command = new ChangePasswordCommand(new ChangePasswordRequestDTO + { + Email = Email, + CurrentPassword = CurrentPassword, + NewPassword = NewPassword + }); + + // Act + var result = await this.handler.Handle(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(Unit.Value); + } + + [Fact] + public async Task Handle_ShouldReturnFail_WhenUserNotFound() + { + // Arrange + this.userManagerMock.Setup(m => m.FindByEmailAsync(Email)).ReturnsAsync((ApplicationUser?)null); + + var command = new ChangePasswordCommand(new ChangePasswordRequestDTO { Email = Email }); + + // Act + var result = await this.handler.Handle(command, CancellationToken.None); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors.First().Message.Should().Be("User not found"); + } + + [Fact] + public async Task Handle_ShouldReturnFail_WhenIdentityChangePasswordFails() + { + // Arrange + var user = new ApplicationUser { Email = Email }; + var identityErrors = new[] { new IdentityError { Description = "Incorrect current password" } }; + + this.userManagerMock.Setup(m => m.FindByEmailAsync(Email)).ReturnsAsync(user); + this.userManagerMock.Setup(m => m.ChangePasswordAsync(user, CurrentPassword, NewPassword)) + .ReturnsAsync(IdentityResult.Failed(identityErrors)); + + var command = new ChangePasswordCommand(new ChangePasswordRequestDTO + { + Email = Email, + CurrentPassword = CurrentPassword, + NewPassword = NewPassword + }); + + // Act + var result = await this.handler.Handle(command, CancellationToken.None); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors.First().Message.Should().Be("Incorrect current password"); + } + + private Mock> CreateUserManagerMock() + { + var store = new Mock>(); + return new Mock>( + store.Object, null, null, null, null, null, null, null, null); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordRequestDTOValidatorTests.cs b/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordRequestDTOValidatorTests.cs new file mode 100644 index 0000000..f451e7f --- /dev/null +++ b/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordRequestDTOValidatorTests.cs @@ -0,0 +1,121 @@ +namespace Streetcode.Auth.XUnitTest.ChangePassword +{ + using FluentValidation.TestHelper; + using Streetcode.Auth.BLL.DTO.Auth; + using Streetcode.Auth.BLL.MediatR.ChangePassword; + using Xunit; + + public class ChangePasswordRequestDTOValidatorTests + { + private readonly ChangePasswordRequestDTOValidator validator; + + public ChangePasswordRequestDTOValidatorTests() + { + this.validator = new ChangePasswordRequestDTOValidator(); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ShouldHaveError_WhenEmailIsEmpty(string email) + { + var model = new ChangePasswordRequestDTO { Email = email }; + var result = this.validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Email) + .WithErrorMessage("Email is required."); + } + + [Theory] + [InlineData("invalid-email")] + [InlineData("@domain.com")] + public void ShouldHaveError_WhenEmailIsInvalid(string email) + { + var model = new ChangePasswordRequestDTO { Email = email }; + var result = this.validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Email) + .WithErrorMessage("Invalid email address format."); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ShouldHaveError_WhenCurrentPasswordIsEmpty(string password) + { + var model = new ChangePasswordRequestDTO { CurrentPassword = password }; + var result = this.validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.CurrentPassword) + .WithErrorMessage("Password is required."); + } + + [Fact] + public void ShouldHaveError_WhenNewPasswordIsTooShort() + { + var model = new ChangePasswordRequestDTO { NewPassword = "Short" }; + var result = this.validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("Password must be at least 6 characters long."); + } + + [Fact] + public void ShouldHaveError_WhenNewPasswordHasNoUppercase() + { + var model = new ChangePasswordRequestDTO { NewPassword = "password123!" }; + var result = this.validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("Password must contain at least one uppercase letter."); + } + + [Fact] + public void ShouldHaveError_WhenNewPasswordHasNoLowercase() + { + var model = new ChangePasswordRequestDTO { NewPassword = "PASSWORD123!" }; + var result = this.validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("Password must contain at least one lowercase letter."); + } + + [Fact] + public void ShouldHaveError_WhenNewPasswordHasNoDigit() + { + var model = new ChangePasswordRequestDTO { NewPassword = "Password!" }; + var result = this.validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("Password must contain at least one number."); + } + + [Fact] + public void ShouldHaveError_WhenNewPasswordHasNoSpecialChar() + { + var model = new ChangePasswordRequestDTO { NewPassword = "Password123" }; + var result = this.validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("Password must contain at least one special character."); + } + + [Fact] + public void ShouldHaveError_WhenNewPasswordIsSameAsCurrent() + { + var model = new ChangePasswordRequestDTO + { + CurrentPassword = "StrongPassword1!", + NewPassword = "StrongPassword1!" + }; + var result = this.validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("New password cannot be the same as the current password."); + } + + [Fact] + public void ShouldNotHaveError_WhenDTOIsValid() + { + var model = new ChangePasswordRequestDTO + { + Email = "user@example.com", + CurrentPassword = "OldPassword1!", + NewPassword = "NewPassword2?" + }; + var result = this.validator.TestValidate(model); + result.ShouldNotHaveAnyValidationErrors(); + } + } +} \ No newline at end of file From 4b690297ad68769b30cd9feed314449e99bbd201 Mon Sep 17 00:00:00 2001 From: Roman Kholod Date: Tue, 3 Mar 2026 21:24:21 +0200 Subject: [PATCH 2/4] implemented logout from all devises after password change --- .../ChangePassword/ChangePasswordCommand.cs | 7 +------ .../ChangePasswordCommandValidator.cs | 7 +------ .../ChangePassword/ChangePasswordHandler.cs | 7 ++++++- .../Controllers/AuthController.cs | 8 ++++--- .../ChangePasswordHandlerTests.cs | 21 +++++++++++++++---- 5 files changed, 30 insertions(+), 20 deletions(-) diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs index 4c31f7e..c66d14c 100644 --- a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using FluentResults; +using FluentResults; using MediatR; using Streetcode.Auth.BLL.DTO.Auth; diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs index 2afeeaf..d7108dd 100644 --- a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using FluentValidation; +using FluentValidation; namespace Streetcode.Auth.BLL.MediatR.ChangePassword { diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs index 7fc359b..e27057f 100644 --- a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs @@ -1,6 +1,7 @@ using FluentResults; using MediatR; using Microsoft.AspNetCore.Identity; +using Streetcode.Auth.BLL.Interfaces; using Streetcode.Auth.DAL.Entities; namespace Streetcode.Auth.BLL.MediatR.ChangePassword @@ -8,10 +9,12 @@ namespace Streetcode.Auth.BLL.MediatR.ChangePassword public class ChangePasswordHandler : IRequestHandler> { private readonly UserManager _userManager; + private readonly ITokenService _tokenService; - public ChangePasswordHandler(UserManager userManager) + public ChangePasswordHandler(UserManager userManager, ITokenService tokenService) { _userManager = userManager; + _tokenService = tokenService; } public async Task> Handle(ChangePasswordCommand request, CancellationToken cancellationToken) @@ -34,6 +37,8 @@ public async Task> Handle(ChangePasswordCommand request, Cancellati return Result.Fail(string.Join(", ", errors)); } + await _tokenService.RevokeAllAsync(user.Id); + return Result.Ok(Unit.Value); } } diff --git a/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs b/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs index 97254a6..edb991d 100644 --- a/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs +++ b/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs @@ -98,10 +98,12 @@ public async Task ChangePassword([FromBody] ChangePasswordRequest if (result.IsSuccess) { - return Ok("Password changed successfully"); + _cookieService.DeleteRefreshTokenCookie(Response); + + return Ok(new { message = "Password changed successfully. You have been logged out of all devices." }); } - return BadRequest(result.Errors[0].Message); + return BadRequest(result.Errors.Select(e => e.Message)); } } -} +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs b/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs index 3fa6055..9391cad 100644 --- a/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs +++ b/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Identity; using Moq; using Streetcode.Auth.BLL.DTO.Auth; +using Streetcode.Auth.BLL.Interfaces; using Streetcode.Auth.BLL.MediatR.ChangePassword; using Streetcode.Auth.DAL.Entities; @@ -11,23 +12,29 @@ namespace Streetcode.Auth.XUnitTest.ChangePassword public class ChangePasswordHandlerTests { private const string Email = "user@example.com"; + private const string UserId = "user-id-123"; private const string CurrentPassword = "OldPassword123!"; private const string NewPassword = "NewPassword456!"; private readonly Mock> userManagerMock; + private readonly Mock tokenServiceMock; private readonly ChangePasswordHandler handler; public ChangePasswordHandlerTests() { this.userManagerMock = this.CreateUserManagerMock(); - this.handler = new ChangePasswordHandler(this.userManagerMock.Object); + this.tokenServiceMock = new Mock(); + + this.handler = new ChangePasswordHandler( + this.userManagerMock.Object, + this.tokenServiceMock.Object); } [Fact] - public async Task Handle_ShouldReturnSuccess_WhenCredentialsAreValid() + public async Task Handle_ShouldReturnSuccessAndRevokeTokens_WhenCredentialsAreValid() { // Arrange - var user = new ApplicationUser { Email = Email }; + var user = new ApplicationUser { Id = UserId, Email = Email }; this.userManagerMock.Setup(m => m.FindByEmailAsync(Email)).ReturnsAsync(user); this.userManagerMock.Setup(m => m.ChangePasswordAsync(user, CurrentPassword, NewPassword)) @@ -46,6 +53,8 @@ public async Task Handle_ShouldReturnSuccess_WhenCredentialsAreValid() // Assert result.IsSuccess.Should().BeTrue(); result.Value.Should().Be(Unit.Value); + + this.tokenServiceMock.Verify(s => s.RevokeAllAsync(UserId), Times.Once); } [Fact] @@ -62,13 +71,15 @@ public async Task Handle_ShouldReturnFail_WhenUserNotFound() // Assert result.IsFailed.Should().BeTrue(); result.Errors.First().Message.Should().Be("User not found"); + + this.tokenServiceMock.Verify(s => s.RevokeAllAsync(It.IsAny()), Times.Never); } [Fact] public async Task Handle_ShouldReturnFail_WhenIdentityChangePasswordFails() { // Arrange - var user = new ApplicationUser { Email = Email }; + var user = new ApplicationUser { Id = UserId, Email = Email }; var identityErrors = new[] { new IdentityError { Description = "Incorrect current password" } }; this.userManagerMock.Setup(m => m.FindByEmailAsync(Email)).ReturnsAsync(user); @@ -88,6 +99,8 @@ public async Task Handle_ShouldReturnFail_WhenIdentityChangePasswordFails() // Assert result.IsFailed.Should().BeTrue(); result.Errors.First().Message.Should().Be("Incorrect current password"); + + this.tokenServiceMock.Verify(s => s.RevokeAllAsync(It.IsAny()), Times.Never); } private Mock> CreateUserManagerMock() From 6b4f178ee5788af77a7e2f966d3073c4a9a626db Mon Sep 17 00:00:00 2001 From: Roman Kholod Date: Thu, 5 Mar 2026 22:03:49 +0200 Subject: [PATCH 3/4] fix --- .../ChangePassword/ChangePasswordCommand.cs | 2 +- .../ChangePassword/ChangePasswordHandler.cs | 5 +++-- .../Controllers/AuthController.cs | 17 +++++++++++++---- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs index c66d14c..5060e65 100644 --- a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs @@ -4,5 +4,5 @@ namespace Streetcode.Auth.BLL.MediatR.ChangePassword { - public record ChangePasswordCommand(ChangePasswordRequestDTO Request) : IRequest>; + public record ChangePasswordCommand(ChangePasswordRequestDTO Request, string Email) : IRequest>; } \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs index e27057f..d1fc8db 100644 --- a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs @@ -1,4 +1,5 @@ -using FluentResults; +using System.Linq; +using FluentResults; using MediatR; using Microsoft.AspNetCore.Identity; using Streetcode.Auth.BLL.Interfaces; @@ -19,7 +20,7 @@ public ChangePasswordHandler(UserManager userManager, ITokenSer public async Task> Handle(ChangePasswordCommand request, CancellationToken cancellationToken) { - var user = await _userManager.FindByEmailAsync(request.Request.Email); + var user = await _userManager.FindByEmailAsync(request.Email); if (user == null) { diff --git a/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs b/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs index edb991d..e539965 100644 --- a/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs +++ b/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs @@ -1,4 +1,6 @@ -using MediatR; +using System.Security.Claims; +using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Streetcode.Auth.BLL.DTO.Auth; using Streetcode.Auth.BLL.MediatR.ChangePassword; @@ -91,16 +93,23 @@ public async Task Logout() return Ok(new { message = "Logged out" }); } + [Authorize] [HttpPost("change-password")] public async Task ChangePassword([FromBody] ChangePasswordRequestDTO request) { - var result = await _mediator.Send(new ChangePasswordCommand(request)); + var userEmail = User.FindFirstValue(ClaimTypes.Email); + + if (string.IsNullOrEmpty(userEmail)) + { + return Unauthorized("User email not found in token."); + } + + var result = await _mediator.Send(new ChangePasswordCommand(request, userEmail)); if (result.IsSuccess) { _cookieService.DeleteRefreshTokenCookie(Response); - - return Ok(new { message = "Password changed successfully. You have been logged out of all devices." }); + return Ok(new { message = "Password changed successfully." }); } return BadRequest(result.Errors.Select(e => e.Message)); From 3ff8c796db652baa3011bdb6964f667eb708790d Mon Sep 17 00:00:00 2001 From: Roman Kholod Date: Thu, 5 Mar 2026 22:14:33 +0200 Subject: [PATCH 4/4] fixed tests --- .../ChangePasswordCommandValidator.cs | 4 ++++ .../ChangePasswordRequestDTOValidator.cs | 4 ---- .../ChangePasswordHandlerTests.cs | 12 ++++++---- .../ChangePasswordRequestDTOValidatorTests.cs | 23 ------------------- 4 files changed, 11 insertions(+), 32 deletions(-) diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs index d7108dd..b6e072f 100644 --- a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs @@ -8,6 +8,10 @@ public ChangePasswordCommandValidator() { RuleFor(x => x.Request) .SetValidator(new ChangePasswordRequestDTOValidator()); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("User email is missing from the identity claim.") + .EmailAddress(); } } } \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordRequestDTOValidator.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordRequestDTOValidator.cs index 6c842b7..38dcbdf 100644 --- a/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordRequestDTOValidator.cs +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordRequestDTOValidator.cs @@ -7,10 +7,6 @@ public class ChangePasswordRequestDTOValidator : AbstractValidator x.Email) - .NotEmpty().WithMessage("Email is required.") - .EmailAddress().WithMessage("Invalid email address format."); - RuleFor(x => x.CurrentPassword) .NotEmpty().WithMessage("Password is required."); diff --git a/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs b/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs index 9391cad..907955d 100644 --- a/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs +++ b/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs @@ -42,10 +42,9 @@ public async Task Handle_ShouldReturnSuccessAndRevokeTokens_WhenCredentialsAreVa var command = new ChangePasswordCommand(new ChangePasswordRequestDTO { - Email = Email, CurrentPassword = CurrentPassword, NewPassword = NewPassword - }); + }, Email); // Act var result = await this.handler.Handle(command, CancellationToken.None); @@ -63,7 +62,11 @@ public async Task Handle_ShouldReturnFail_WhenUserNotFound() // Arrange this.userManagerMock.Setup(m => m.FindByEmailAsync(Email)).ReturnsAsync((ApplicationUser?)null); - var command = new ChangePasswordCommand(new ChangePasswordRequestDTO { Email = Email }); + var command = new ChangePasswordCommand(new ChangePasswordRequestDTO + { + CurrentPassword = CurrentPassword, + NewPassword = NewPassword + }, Email); // Act var result = await this.handler.Handle(command, CancellationToken.None); @@ -88,10 +91,9 @@ public async Task Handle_ShouldReturnFail_WhenIdentityChangePasswordFails() var command = new ChangePasswordCommand(new ChangePasswordRequestDTO { - Email = Email, CurrentPassword = CurrentPassword, NewPassword = NewPassword - }); + }, Email); // Act var result = await this.handler.Handle(command, CancellationToken.None); diff --git a/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordRequestDTOValidatorTests.cs b/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordRequestDTOValidatorTests.cs index f451e7f..f61802a 100644 --- a/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordRequestDTOValidatorTests.cs +++ b/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordRequestDTOValidatorTests.cs @@ -14,28 +14,6 @@ public ChangePasswordRequestDTOValidatorTests() this.validator = new ChangePasswordRequestDTOValidator(); } - [Theory] - [InlineData("")] - [InlineData(null)] - public void ShouldHaveError_WhenEmailIsEmpty(string email) - { - var model = new ChangePasswordRequestDTO { Email = email }; - var result = this.validator.TestValidate(model); - result.ShouldHaveValidationErrorFor(x => x.Email) - .WithErrorMessage("Email is required."); - } - - [Theory] - [InlineData("invalid-email")] - [InlineData("@domain.com")] - public void ShouldHaveError_WhenEmailIsInvalid(string email) - { - var model = new ChangePasswordRequestDTO { Email = email }; - var result = this.validator.TestValidate(model); - result.ShouldHaveValidationErrorFor(x => x.Email) - .WithErrorMessage("Invalid email address format."); - } - [Theory] [InlineData("")] [InlineData(null)] @@ -110,7 +88,6 @@ public void ShouldNotHaveError_WhenDTOIsValid() { var model = new ChangePasswordRequestDTO { - Email = "user@example.com", CurrentPassword = "OldPassword1!", NewPassword = "NewPassword2?" };