[CLAUDE] Auth: user tự đổi mật khẩu (self-service change-password) — BE + 2 FE + test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s

Go-live request anh Kiệt FDC (Zalo "user chưa tự đổi pass được hả em"): trước đây chỉ admin
Reset hộ (POST users/{id}/reset-password), user KHÔNG tự đổi được. Thêm self-service:

- BE: ChangePasswordCommand + POST /api/auth/change-password ([Authorize]). Lấy user qua
  ICurrentUser.UserId; UserManager.ChangePasswordAsync verify mật khẩu HIỆN TẠI; sai →
  ValidationException field CurrentPassword "Mật khẩu hiện tại không đúng."; rule mật khẩu mới
  ≥ 12 + khác mật khẩu cũ (validator); sau đổi vô hiệu refresh token (mirror ResetPassword).
  Qualify SolutionErp...ValidationException (tránh CS0104 clash với FluentValidation).
- FE 2 app (SHA-mirror): ChangePasswordDialog (3 ô current/new/confirm + validate client:
  ≥12, khớp, khác cũ) wire vào menu tài khoản TopBar — "Đổi mật khẩu" trên "Đăng xuất".
- Test: +ChangePasswordCommandTests (đúng/sai pass cũ keyed-field + msg, <12 boundary, ==cũ,
  chưa-auth/UserId-null/user-not-found, RefreshToken cleared). Full suite 431 PASS (45D + 386I).

Build: BE 0 warn/0 err · fe-user + fe-admin build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-26 12:27:52 +07:00
parent 55494ad477
commit e81e87ff9f
7 changed files with 569 additions and 2 deletions

View File

@ -1,6 +1,7 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Auth.Commands.ChangePassword;
using SolutionErp.Application.Auth.Commands.Login;
using SolutionErp.Application.Auth.Commands.Refresh;
using SolutionErp.Application.Auth.Dtos;
@ -38,4 +39,13 @@ public class AuthController : ControllerBase
{
return NoContent();
}
// Self-service: user đang đăng nhập tự đổi mật khẩu (xác thực mật khẩu hiện tại).
[HttpPost("change-password")]
[Authorize]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordCommand command, CancellationToken ct)
{
await _mediator.Send(command, ct);
return NoContent();
}
}

View File

@ -0,0 +1,58 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Auth.Commands.ChangePassword;
// Self-service đổi mật khẩu — user đang đăng nhập TỰ đổi, BẮT BUỘC nhập mật khẩu hiện tại
// để xác thực. Khác ResetPasswordCommand (admin đặt pass hộ theo userId, KHÔNG cần pass cũ).
public record ChangePasswordCommand(string CurrentPassword, string NewPassword) : IRequest;
public class ChangePasswordCommandValidator : AbstractValidator<ChangePasswordCommand>
{
public ChangePasswordCommandValidator()
{
RuleFor(x => x.CurrentPassword)
.NotEmpty().WithMessage("Vui lòng nhập mật khẩu hiện tại.");
RuleFor(x => x.NewPassword)
.NotEmpty().WithMessage("Vui lòng nhập mật khẩu mới.")
.MinimumLength(12).WithMessage("Mật khẩu mới phải có ít nhất 12 ký tự.");
RuleFor(x => x.NewPassword)
.NotEqual(x => x.CurrentPassword).WithMessage("Mật khẩu mới phải khác mật khẩu hiện tại.")
.When(x => !string.IsNullOrEmpty(x.NewPassword) && !string.IsNullOrEmpty(x.CurrentPassword));
}
}
public class ChangePasswordCommandHandler(
UserManager<User> userManager,
ICurrentUser currentUser) : IRequestHandler<ChangePasswordCommand>
{
public async Task Handle(ChangePasswordCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var user = await userManager.FindByIdAsync(currentUser.UserId.Value.ToString())
?? throw new UnauthorizedException();
// Identity tự verify CurrentPassword — sai mật khẩu cũ => result.Errors có code "PasswordMismatch".
// Luật mật khẩu mới (độ dài, ...) cũng do Identity + validator ở trên kiểm.
var result = await userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
if (!result.Succeeded)
{
var failures = result.Errors.Select(e => new FluentValidation.Results.ValidationFailure(
e.Code == "PasswordMismatch" ? nameof(request.CurrentPassword) : nameof(request.NewPassword),
e.Code == "PasswordMismatch" ? "Mật khẩu hiện tại không đúng." : e.Description));
throw new SolutionErp.Application.Common.Exceptions.ValidationException(failures);
}
// Vô hiệu refresh token (mirror ResetPasswordCommand) → các phiên hiện có sẽ phải
// đăng nhập lại bằng mật khẩu mới khi access token hết hạn.
user.RefreshToken = null;
user.RefreshTokenExpiresAt = null;
await userManager.UpdateAsync(user);
}
}