feat: 이메일 인증 인프라 및 API 구현 (#64) #65

Merged
seonkyu.kim merged 2 commits from feature/#64-email-verify into develop 2026-02-10 02:03:50 +00:00
9 changed files with 162 additions and 2 deletions

View File

@ -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<IActionResult> VerifyEmailAsync([FromBody] EmailVerifyRequestDto request)
{
await _authService.VerifyEmailAsync(request);
return Ok(ApiResponse.Success());
}
[HttpPost("password/change")]
[Authorize]
[SwaggerOperation(

View File

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

View File

@ -11,6 +11,7 @@ public interface IAuthService
Task LogoutAsync(long adminId);
Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request);
Task<EmailCheckResponseDto> CheckEmailAsync(EmailCheckRequestDto request);
Task VerifyEmailAsync(EmailVerifyRequestDto request);
Task<ProfileResponseDto> GetProfileAsync(long adminId);
Task<ProfileResponseDto> UpdateProfileAsync(long adminId, UpdateProfileRequestDto request);
}

View File

@ -0,0 +1,7 @@
namespace SPMS.Application.Interfaces;
public interface IEmailService
{
Task SendVerificationCodeAsync(string email, string code);
Task SendPasswordResetTokenAsync(string email, string token);
}

View File

@ -0,0 +1,8 @@
namespace SPMS.Application.Interfaces;
public interface ITokenStore
{
Task StoreAsync(string key, string value, TimeSpan expiry);
Task<string?> GetAsync(string key);
Task RemoveAsync(string key);
}

View File

@ -17,17 +17,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> jwtSettings)
IOptions<JwtSettings> jwtSettings,
ITokenStore tokenStore,
IEmailService emailService)
{
_adminRepository = adminRepository;
_unitOfWork = unitOfWork;
_jwtService = jwtService;
_jwtSettings = jwtSettings.Value;
_tokenStore = tokenStore;
_emailService = emailService;
}
public async Task<SignupResponseDto> SignupAsync(SignupRequestDto request)
@ -62,7 +68,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,
@ -227,6 +241,42 @@ public class AuthService : IAuthService
};
}
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}");
}
public async Task<ProfileResponseDto> GetProfileAsync(long adminId)
{
var admin = await _adminRepository.GetByIdAsync(adminId);

View File

@ -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<IE2EEService, E2EEService>();
services.AddSingleton<ICredentialEncryptionService, CredentialEncryptionService>();
// Token Store & Email Service
services.AddMemoryCache();
services.AddSingleton<ITokenStore, InMemoryTokenStore>();
services.AddSingleton<IEmailService, ConsoleEmailService>();
return services;
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.Extensions.Logging;
using SPMS.Application.Interfaces;
namespace SPMS.Infrastructure.Services;
public class ConsoleEmailService : IEmailService
{
private readonly ILogger<ConsoleEmailService> _logger;
public ConsoleEmailService(ILogger<ConsoleEmailService> 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;
}
}

View File

@ -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<string?> GetAsync(string key)
{
_cache.TryGetValue(key, out string? value);
return Task.FromResult(value);
}
public Task RemoveAsync(string key)
{
_cache.Remove(key);
return Task.CompletedTask;
}
}