feat: 비밀번호 찾기/재설정 API 구현 (#66) #67
46
SPMS.API/Controllers/PasswordController.cs
Normal file
46
SPMS.API/Controllers/PasswordController.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
10
SPMS.Application/DTOs/Account/PasswordForgotRequestDto.cs
Normal file
10
SPMS.Application/DTOs/Account/PasswordForgotRequestDto.cs
Normal 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;
|
||||
}
|
||||
20
SPMS.Application/DTOs/Account/PasswordResetRequestDto.cs
Normal file
20
SPMS.Application/DTOs/Account/PasswordResetRequestDto.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ 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);
|
||||
Task<ProfileResponseDto> GetProfileAsync(long adminId);
|
||||
Task<ProfileResponseDto> UpdateProfileAsync(long adminId, UpdateProfileRequestDto request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ProfileResponseDto> GetProfileAsync(long adminId)
|
||||
{
|
||||
var admin = await _adminRepository.GetByIdAsync(adminId);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user