From ccdfcbd62e64e03543611d5acbb435b9caab4b70 Mon Sep 17 00:00:00 2001 From: SEAN Date: Tue, 10 Feb 2026 10:52:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EB=B0=8F=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ITokenStore, IEmailService 인터페이스 정의 - InMemoryTokenStore (IMemoryCache 기반), ConsoleEmailService (로그 출력) 구현 - SignupAsync에 6자리 인증 코드 생성/저장/발송 로직 추가 - VerifyEmailAsync 구현 (코드 검증 → EmailVerified 업데이트) - POST /v1/in/auth/email/verify 엔드포인트 추가 - DI 등록 (ITokenStore, IEmailService, MemoryCache) --- SPMS.API/Controllers/AuthController.cs | 13 +++++ .../DTOs/Auth/EmailVerifyRequestDto.cs | 13 +++++ SPMS.Application/Interfaces/IAuthService.cs | 1 + SPMS.Application/Interfaces/IEmailService.cs | 7 +++ SPMS.Application/Interfaces/ITokenStore.cs | 8 +++ SPMS.Application/Services/AuthService.cs | 54 ++++++++++++++++++- SPMS.Infrastructure/DependencyInjection.cs | 6 +++ .../Services/ConsoleEmailService.cs | 30 +++++++++++ .../Services/InMemoryTokenStore.cs | 32 +++++++++++ 9 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 SPMS.Application/DTOs/Auth/EmailVerifyRequestDto.cs create mode 100644 SPMS.Application/Interfaces/IEmailService.cs create mode 100644 SPMS.Application/Interfaces/ITokenStore.cs create mode 100644 SPMS.Infrastructure/Services/ConsoleEmailService.cs create mode 100644 SPMS.Infrastructure/Services/InMemoryTokenStore.cs diff --git a/SPMS.API/Controllers/AuthController.cs b/SPMS.API/Controllers/AuthController.cs index a6d9158..5ea9567 100644 --- a/SPMS.API/Controllers/AuthController.cs +++ b/SPMS.API/Controllers/AuthController.cs @@ -91,6 +91,19 @@ public class AuthController : ControllerBase return Ok(ApiResponse.Success()); } + [HttpPost("email/verify")] + [AllowAnonymous] + [SwaggerOperation( + Summary = "이메일 인증", + Description = "회원가입 시 발송된 인증 코드로 이메일을 인증합니다.")] + [SwaggerResponse(200, "이메일 인증 성공")] + [SwaggerResponse(400, "인증 코드 불일치 또는 만료")] + public async Task VerifyEmailAsync([FromBody] EmailVerifyRequestDto request) + { + await _authService.VerifyEmailAsync(request); + return Ok(ApiResponse.Success()); + } + [HttpPost("password/change")] [Authorize] [SwaggerOperation( diff --git a/SPMS.Application/DTOs/Auth/EmailVerifyRequestDto.cs b/SPMS.Application/DTOs/Auth/EmailVerifyRequestDto.cs new file mode 100644 index 0000000..99155f9 --- /dev/null +++ b/SPMS.Application/DTOs/Auth/EmailVerifyRequestDto.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Auth; + +public class EmailVerifyRequestDto +{ + [Required(ErrorMessage = "이메일은 필수입니다.")] + [EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")] + public string Email { get; set; } = string.Empty; + + [Required(ErrorMessage = "인증 코드는 필수입니다.")] + public string Code { get; set; } = string.Empty; +} diff --git a/SPMS.Application/Interfaces/IAuthService.cs b/SPMS.Application/Interfaces/IAuthService.cs index 6c508dd..f082ef6 100644 --- a/SPMS.Application/Interfaces/IAuthService.cs +++ b/SPMS.Application/Interfaces/IAuthService.cs @@ -10,4 +10,5 @@ public interface IAuthService Task LogoutAsync(long adminId); Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request); Task CheckEmailAsync(EmailCheckRequestDto request); + Task VerifyEmailAsync(EmailVerifyRequestDto request); } diff --git a/SPMS.Application/Interfaces/IEmailService.cs b/SPMS.Application/Interfaces/IEmailService.cs new file mode 100644 index 0000000..627697c --- /dev/null +++ b/SPMS.Application/Interfaces/IEmailService.cs @@ -0,0 +1,7 @@ +namespace SPMS.Application.Interfaces; + +public interface IEmailService +{ + Task SendVerificationCodeAsync(string email, string code); + Task SendPasswordResetTokenAsync(string email, string token); +} diff --git a/SPMS.Application/Interfaces/ITokenStore.cs b/SPMS.Application/Interfaces/ITokenStore.cs new file mode 100644 index 0000000..4377211 --- /dev/null +++ b/SPMS.Application/Interfaces/ITokenStore.cs @@ -0,0 +1,8 @@ +namespace SPMS.Application.Interfaces; + +public interface ITokenStore +{ + Task StoreAsync(string key, string value, TimeSpan expiry); + Task GetAsync(string key); + Task RemoveAsync(string key); +} diff --git a/SPMS.Application/Services/AuthService.cs b/SPMS.Application/Services/AuthService.cs index f0995b4..f500d6d 100644 --- a/SPMS.Application/Services/AuthService.cs +++ b/SPMS.Application/Services/AuthService.cs @@ -16,17 +16,23 @@ public class AuthService : IAuthService private readonly IUnitOfWork _unitOfWork; private readonly IJwtService _jwtService; private readonly JwtSettings _jwtSettings; + private readonly ITokenStore _tokenStore; + private readonly IEmailService _emailService; public AuthService( IAdminRepository adminRepository, IUnitOfWork unitOfWork, IJwtService jwtService, - IOptions jwtSettings) + IOptions jwtSettings, + ITokenStore tokenStore, + IEmailService emailService) { _adminRepository = adminRepository; _unitOfWork = unitOfWork; _jwtService = jwtService; _jwtSettings = jwtSettings.Value; + _tokenStore = tokenStore; + _emailService = emailService; } public async Task SignupAsync(SignupRequestDto request) @@ -61,7 +67,15 @@ public class AuthService : IAuthService await _adminRepository.AddAsync(admin); await _unitOfWork.SaveChangesAsync(); - // 5. 응답 반환 + // 5. 이메일 인증 코드 생성/저장/발송 + var verificationCode = Random.Shared.Next(100000, 999999).ToString(); + await _tokenStore.StoreAsync( + $"email_verify:{request.Email}", + verificationCode, + TimeSpan.FromHours(1)); + await _emailService.SendVerificationCodeAsync(request.Email, verificationCode); + + // 6. 응답 반환 return new SignupResponseDto { AdminCode = admin.AdminCode, @@ -225,4 +239,40 @@ public class AuthService : IAuthService IsAvailable = !exists }; } + + public async Task VerifyEmailAsync(EmailVerifyRequestDto request) + { + var admin = await _adminRepository.GetByEmailAsync(request.Email); + if (admin is null) + { + throw new SpmsException( + ErrorCodes.VerificationCodeError, + "인증 코드가 유효하지 않습니다.", + 400); + } + + if (admin.EmailVerified) + { + throw new SpmsException( + ErrorCodes.VerificationCodeError, + "이미 인증된 이메일입니다.", + 400); + } + + var storedCode = await _tokenStore.GetAsync($"email_verify:{request.Email}"); + if (storedCode is null || storedCode != request.Code) + { + throw new SpmsException( + ErrorCodes.VerificationCodeError, + "인증 코드가 유효하지 않거나 만료되었습니다.", + 400); + } + + admin.EmailVerified = true; + admin.EmailVerifiedAt = DateTime.UtcNow; + _adminRepository.Update(admin); + await _unitOfWork.SaveChangesAsync(); + + await _tokenStore.RemoveAsync($"email_verify:{request.Email}"); + } } diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index b3d8291..599cfb8 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -7,6 +7,7 @@ using SPMS.Infrastructure.Auth; using SPMS.Infrastructure.Persistence; using SPMS.Infrastructure.Persistence.Repositories; using SPMS.Infrastructure.Security; +using SPMS.Infrastructure.Services; namespace SPMS.Infrastructure; @@ -32,6 +33,11 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); + // Token Store & Email Service + services.AddMemoryCache(); + services.AddSingleton(); + services.AddSingleton(); + return services; } } diff --git a/SPMS.Infrastructure/Services/ConsoleEmailService.cs b/SPMS.Infrastructure/Services/ConsoleEmailService.cs new file mode 100644 index 0000000..17ad79d --- /dev/null +++ b/SPMS.Infrastructure/Services/ConsoleEmailService.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; +using SPMS.Application.Interfaces; + +namespace SPMS.Infrastructure.Services; + +public class ConsoleEmailService : IEmailService +{ + private readonly ILogger _logger; + + public ConsoleEmailService(ILogger logger) + { + _logger = logger; + } + + public Task SendVerificationCodeAsync(string email, string code) + { + _logger.LogInformation( + "[EMAIL] 이메일 인증 코드 발송 → To: {Email}, Code: {Code}", + email, code); + return Task.CompletedTask; + } + + public Task SendPasswordResetTokenAsync(string email, string token) + { + _logger.LogInformation( + "[EMAIL] 비밀번호 재설정 토큰 발송 → To: {Email}, Token: {Token}", + email, token); + return Task.CompletedTask; + } +} diff --git a/SPMS.Infrastructure/Services/InMemoryTokenStore.cs b/SPMS.Infrastructure/Services/InMemoryTokenStore.cs new file mode 100644 index 0000000..c417a58 --- /dev/null +++ b/SPMS.Infrastructure/Services/InMemoryTokenStore.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Caching.Memory; +using SPMS.Application.Interfaces; + +namespace SPMS.Infrastructure.Services; + +public class InMemoryTokenStore : ITokenStore +{ + private readonly IMemoryCache _cache; + + public InMemoryTokenStore(IMemoryCache cache) + { + _cache = cache; + } + + public Task StoreAsync(string key, string value, TimeSpan expiry) + { + _cache.Set(key, value, expiry); + return Task.CompletedTask; + } + + public Task GetAsync(string key) + { + _cache.TryGetValue(key, out string? value); + return Task.FromResult(value); + } + + public Task RemoveAsync(string key) + { + _cache.Remove(key); + return Task.CompletedTask; + } +}