From 5f25614e53beb66c310ea918f374d6d3d12a4994 Mon Sep 17 00:00:00 2001 From: SEAN Date: Tue, 10 Feb 2026 10:56:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0/=EC=9E=AC=EC=84=A4=EC=A0=95=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PasswordForgotRequestDto, PasswordResetRequestDto 생성 - IAuthService에 ForgotPasswordAsync, ResetPasswordAsync 추가 - ForgotPasswordAsync: UUID 토큰 생성 → ITokenStore 저장(30분) → 이메일 발송 - ResetPasswordAsync: 토큰 검증 → BCrypt 해싱 → 비밀번호 저장 - PasswordController 생성 (v1/in/account/password) - 보안: forgot에서 이메일 미존재 시에도 동일한 성공 응답 --- SPMS.API/Controllers/PasswordController.cs | 46 +++++++++++++++++++ .../DTOs/Account/PasswordForgotRequestDto.cs | 10 ++++ .../DTOs/Account/PasswordResetRequestDto.cs | 20 ++++++++ SPMS.Application/Interfaces/IAuthService.cs | 3 ++ SPMS.Application/Services/AuthService.cs | 44 ++++++++++++++++++ 5 files changed, 123 insertions(+) create mode 100644 SPMS.API/Controllers/PasswordController.cs create mode 100644 SPMS.Application/DTOs/Account/PasswordForgotRequestDto.cs create mode 100644 SPMS.Application/DTOs/Account/PasswordResetRequestDto.cs 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 f082ef6..43238bb 100644 --- a/SPMS.Application/Interfaces/IAuthService.cs +++ b/SPMS.Application/Interfaces/IAuthService.cs @@ -1,3 +1,4 @@ +using SPMS.Application.DTOs.Account; using SPMS.Application.DTOs.Auth; namespace SPMS.Application.Interfaces; @@ -11,4 +12,6 @@ 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); } diff --git a/SPMS.Application/Services/AuthService.cs b/SPMS.Application/Services/AuthService.cs index f500d6d..db3c331 100644 --- a/SPMS.Application/Services/AuthService.cs +++ b/SPMS.Application/Services/AuthService.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Options; +using SPMS.Application.DTOs.Account; using SPMS.Application.DTOs.Auth; using SPMS.Application.Interfaces; using SPMS.Application.Settings; @@ -275,4 +276,47 @@ 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}"); + } }