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