diff --git a/SPMS.API/Controllers/PasswordController.cs b/SPMS.API/Controllers/PasswordController.cs new file mode 100644 index 0000000..1c53e3d --- /dev/null +++ b/SPMS.API/Controllers/PasswordController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using SPMS.Application.DTOs.Account; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; + +namespace SPMS.API.Controllers; + +[ApiController] +[Route("v1/in/account/password")] +[ApiExplorerSettings(GroupName = "account")] +[AllowAnonymous] +public class PasswordController : ControllerBase +{ + private readonly IAuthService _authService; + + public PasswordController(IAuthService authService) + { + _authService = authService; + } + + [HttpPost("forgot")] + [SwaggerOperation( + Summary = "비밀번호 찾기", + Description = "등록된 이메일로 비밀번호 재설정 토큰을 발송합니다. 보안을 위해 이메일 존재 여부와 관계없이 동일한 응답을 반환합니다.")] + [SwaggerResponse(200, "재설정 메일 발송 완료")] + [SwaggerResponse(400, "잘못된 요청")] + public async Task ForgotPasswordAsync([FromBody] PasswordForgotRequestDto request) + { + await _authService.ForgotPasswordAsync(request); + return Ok(ApiResponse.Success()); + } + + [HttpPost("reset")] + [SwaggerOperation( + Summary = "비밀번호 재설정", + Description = "이메일로 받은 재설정 토큰과 새 비밀번호로 비밀번호를 재설정합니다.")] + [SwaggerResponse(200, "비밀번호 재설정 성공")] + [SwaggerResponse(400, "토큰 불일치 또는 만료")] + public async Task ResetPasswordAsync([FromBody] PasswordResetRequestDto request) + { + await _authService.ResetPasswordAsync(request); + return Ok(ApiResponse.Success()); + } +} diff --git a/SPMS.Application/DTOs/Account/PasswordForgotRequestDto.cs b/SPMS.Application/DTOs/Account/PasswordForgotRequestDto.cs new file mode 100644 index 0000000..6bc9871 --- /dev/null +++ b/SPMS.Application/DTOs/Account/PasswordForgotRequestDto.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Account; + +public class PasswordForgotRequestDto +{ + [Required(ErrorMessage = "이메일은 필수입니다.")] + [EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")] + public string Email { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/Account/PasswordResetRequestDto.cs b/SPMS.Application/DTOs/Account/PasswordResetRequestDto.cs new file mode 100644 index 0000000..f382ea5 --- /dev/null +++ b/SPMS.Application/DTOs/Account/PasswordResetRequestDto.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Account; + +public class PasswordResetRequestDto +{ + [Required(ErrorMessage = "이메일은 필수입니다.")] + [EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")] + public string Email { get; set; } = string.Empty; + + [Required(ErrorMessage = "재설정 토큰은 필수입니다.")] + [JsonPropertyName("reset_token")] + public string ResetToken { get; set; } = string.Empty; + + [Required(ErrorMessage = "새 비밀번호는 필수입니다.")] + [MinLength(8, ErrorMessage = "비밀번호는 8자 이상이어야 합니다.")] + [JsonPropertyName("new_password")] + public string NewPassword { get; set; } = string.Empty; +} diff --git a/SPMS.Application/Interfaces/IAuthService.cs b/SPMS.Application/Interfaces/IAuthService.cs index 02b7201..baeee2b 100644 --- a/SPMS.Application/Interfaces/IAuthService.cs +++ b/SPMS.Application/Interfaces/IAuthService.cs @@ -12,6 +12,8 @@ public interface IAuthService Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request); Task CheckEmailAsync(EmailCheckRequestDto request); Task VerifyEmailAsync(EmailVerifyRequestDto request); + Task ForgotPasswordAsync(PasswordForgotRequestDto request); + Task ResetPasswordAsync(PasswordResetRequestDto request); Task GetProfileAsync(long adminId); Task UpdateProfileAsync(long adminId, UpdateProfileRequestDto request); } diff --git a/SPMS.Application/Services/AuthService.cs b/SPMS.Application/Services/AuthService.cs index 7ee4c33..1d20ab6 100644 --- a/SPMS.Application/Services/AuthService.cs +++ b/SPMS.Application/Services/AuthService.cs @@ -277,6 +277,49 @@ public class AuthService : IAuthService await _tokenStore.RemoveAsync($"email_verify:{request.Email}"); } + public async Task ForgotPasswordAsync(PasswordForgotRequestDto request) + { + var admin = await _adminRepository.GetByEmailAsync(request.Email); + + // 보안: 이메일 존재 여부와 관계없이 동일한 성공 응답 반환 + if (admin is null) + return; + + var resetToken = Guid.NewGuid().ToString("N"); + await _tokenStore.StoreAsync( + $"password_reset:{request.Email}", + resetToken, + TimeSpan.FromMinutes(30)); + await _emailService.SendPasswordResetTokenAsync(request.Email, resetToken); + } + + public async Task ResetPasswordAsync(PasswordResetRequestDto request) + { + var storedToken = await _tokenStore.GetAsync($"password_reset:{request.Email}"); + if (storedToken is null || storedToken != request.ResetToken) + { + throw new SpmsException( + ErrorCodes.ResetTokenError, + "재설정 토큰이 유효하지 않거나 만료되었습니다.", + 400); + } + + var admin = await _adminRepository.GetByEmailAsync(request.Email); + if (admin is null) + { + throw new SpmsException( + ErrorCodes.ResetTokenError, + "재설정 토큰이 유효하지 않거나 만료되었습니다.", + 400); + } + + admin.Password = BCrypt.Net.BCrypt.HashPassword(request.NewPassword); + _adminRepository.Update(admin); + await _unitOfWork.SaveChangesAsync(); + + await _tokenStore.RemoveAsync($"password_reset:{request.Email}"); + } + public async Task GetProfileAsync(long adminId) { var admin = await _adminRepository.GetByIdAsync(adminId);