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..5060e65 --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,8 @@ +using FluentResults; +using MediatR; +using Streetcode.Auth.BLL.DTO.Auth; + +namespace Streetcode.Auth.BLL.MediatR.ChangePassword +{ + public record ChangePasswordCommand(ChangePasswordRequestDTO Request, string Email) : 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..b6e072f --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordCommandValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; + +namespace Streetcode.Auth.BLL.MediatR.ChangePassword +{ + public class ChangePasswordCommandValidator : AbstractValidator + { + 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/ChangePasswordHandler.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs new file mode 100644 index 0000000..d1fc8db --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordHandler.cs @@ -0,0 +1,46 @@ +using System.Linq; +using FluentResults; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Streetcode.Auth.BLL.Interfaces; +using Streetcode.Auth.DAL.Entities; + +namespace Streetcode.Auth.BLL.MediatR.ChangePassword +{ + public class ChangePasswordHandler : IRequestHandler> + { + private readonly UserManager _userManager; + private readonly ITokenService _tokenService; + + public ChangePasswordHandler(UserManager userManager, ITokenService tokenService) + { + _userManager = userManager; + _tokenService = tokenService; + } + + public async Task> Handle(ChangePasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByEmailAsync(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)); + } + + await _tokenService.RevokeAllAsync(user.Id); + + 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..38dcbdf --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/ChangePassword/ChangePasswordRequestDTOValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Streetcode.Auth.BLL.DTO.Auth; + +namespace Streetcode.Auth.BLL.MediatR.ChangePassword +{ + public class ChangePasswordRequestDTOValidator : AbstractValidator + { + public ChangePasswordRequestDTOValidator() + { + 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..e539965 100644 --- a/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs +++ b/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs @@ -1,6 +1,9 @@ -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; using Streetcode.Auth.BLL.MediatR.Login; using Streetcode.Auth.BLL.MediatR.Logout; using Streetcode.Auth.BLL.MediatR.RefreshToken; @@ -89,5 +92,27 @@ public async Task Logout() return Ok(new { message = "Logged out" }); } + + [Authorize] + [HttpPost("change-password")] + public async Task ChangePassword([FromBody] ChangePasswordRequestDTO 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." }); + } + + 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 new file mode 100644 index 0000000..907955d --- /dev/null +++ b/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordHandlerTests.cs @@ -0,0 +1,115 @@ +using FluentAssertions; +using MediatR; +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; + +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.tokenServiceMock = new Mock(); + + this.handler = new ChangePasswordHandler( + this.userManagerMock.Object, + this.tokenServiceMock.Object); + } + + [Fact] + public async Task Handle_ShouldReturnSuccessAndRevokeTokens_WhenCredentialsAreValid() + { + // Arrange + 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)) + .ReturnsAsync(IdentityResult.Success); + + var command = new ChangePasswordCommand(new ChangePasswordRequestDTO + { + CurrentPassword = CurrentPassword, + NewPassword = NewPassword + }, Email); + + // Act + var result = await this.handler.Handle(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(Unit.Value); + + this.tokenServiceMock.Verify(s => s.RevokeAllAsync(UserId), Times.Once); + } + + [Fact] + public async Task Handle_ShouldReturnFail_WhenUserNotFound() + { + // Arrange + this.userManagerMock.Setup(m => m.FindByEmailAsync(Email)).ReturnsAsync((ApplicationUser?)null); + + var command = new ChangePasswordCommand(new ChangePasswordRequestDTO + { + CurrentPassword = CurrentPassword, + NewPassword = NewPassword + }, 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"); + + this.tokenServiceMock.Verify(s => s.RevokeAllAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_ShouldReturnFail_WhenIdentityChangePasswordFails() + { + // Arrange + 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); + this.userManagerMock.Setup(m => m.ChangePasswordAsync(user, CurrentPassword, NewPassword)) + .ReturnsAsync(IdentityResult.Failed(identityErrors)); + + var command = new ChangePasswordCommand(new ChangePasswordRequestDTO + { + CurrentPassword = CurrentPassword, + NewPassword = NewPassword + }, Email); + + // Act + var result = await this.handler.Handle(command, CancellationToken.None); + + // 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() + { + 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..f61802a --- /dev/null +++ b/Streetcode/Streetcode.Auth.XUnitTest/ChangePassword/ChangePasswordRequestDTOValidatorTests.cs @@ -0,0 +1,98 @@ +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_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 + { + CurrentPassword = "OldPassword1!", + NewPassword = "NewPassword2?" + }; + var result = this.validator.TestValidate(model); + result.ShouldNotHaveAnyValidationErrors(); + } + } +} \ No newline at end of file