feat: 비밀번호 찾기/재설정 API 구현 (#66)

- PasswordForgotRequestDto, PasswordResetRequestDto 생성
- IAuthService에 ForgotPasswordAsync, ResetPasswordAsync 추가
- ForgotPasswordAsync: UUID 토큰 생성 → ITokenStore 저장(30분) → 이메일 발송
- ResetPasswordAsync: 토큰 검증 → BCrypt 해싱 → 비밀번호 저장
- PasswordController 생성 (v1/in/account/password)
- 보안: forgot에서 이메일 미존재 시에도 동일한 성공 응답
This commit is contained in:
SEAN 2026-02-10 10:56:35 +09:00
parent ccdfcbd62e
commit 5f25614e53
5 changed files with 123 additions and 0 deletions

View File

@ -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<IActionResult> ForgotPasswordAsync([FromBody] PasswordForgotRequestDto request)
{
await _authService.ForgotPasswordAsync(request);
return Ok(ApiResponse.Success());
}
[HttpPost("reset")]
[SwaggerOperation(
Summary = "비밀번호 재설정",
Description = "이메일로 받은 재설정 토큰과 새 비밀번호로 비밀번호를 재설정합니다.")]
[SwaggerResponse(200, "비밀번호 재설정 성공")]
[SwaggerResponse(400, "토큰 불일치 또는 만료")]
public async Task<IActionResult> ResetPasswordAsync([FromBody] PasswordResetRequestDto request)
{
await _authService.ResetPasswordAsync(request);
return Ok(ApiResponse.Success());
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<EmailCheckResponseDto> CheckEmailAsync(EmailCheckRequestDto request);
Task VerifyEmailAsync(EmailVerifyRequestDto request);
Task ForgotPasswordAsync(PasswordForgotRequestDto request);
Task ResetPasswordAsync(PasswordResetRequestDto request);
}

View File

@ -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}");
}
}