From 3cc99c02847560ec499d87969b752f63b35d1564 Mon Sep 17 00:00:00 2001 From: SEAN Date: Wed, 25 Feb 2026 10:38:41 +0900 Subject: [PATCH] =?UTF-8?q?improvement:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D/=EC=9E=AC=EC=A0=84=EC=86=A1=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20(#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify API: verifySessionId 기반 입력 지원 (email 하위호환) - verify API: 시도 횟수 5회 제한 (30분 TTL) - verify API: 응답에 verified, nextAction 필드 추가 - resend API 신규: POST /v1/in/auth/email/verify/resend - resend API: 60초 쿨다운, 기존 코드 자동 무효화 - email_verify TTL 1시간→5분 변경 (signup/login 포함) - ErrorCodes 추가: VerifyResendCooldown(116), VerifyAttemptExceeded(117) Closes #205 --- SPMS.API/Controllers/AuthController.cs | 23 +++- .../DTOs/Auth/EmailResendRequestDto.cs | 11 ++ .../DTOs/Auth/EmailResendResponseDto.cs | 15 +++ .../DTOs/Auth/EmailVerifyRequestDto.cs | 9 +- .../DTOs/Auth/EmailVerifyResponseDto.cs | 12 ++ SPMS.Application/Interfaces/IAuthService.cs | 3 +- SPMS.Application/Services/AuthService.cs | 111 ++++++++++++++---- SPMS.Domain/Common/ErrorCodes.cs | 2 + 8 files changed, 156 insertions(+), 30 deletions(-) create mode 100644 SPMS.Application/DTOs/Auth/EmailResendRequestDto.cs create mode 100644 SPMS.Application/DTOs/Auth/EmailResendResponseDto.cs create mode 100644 SPMS.Application/DTOs/Auth/EmailVerifyResponseDto.cs diff --git a/SPMS.API/Controllers/AuthController.cs b/SPMS.API/Controllers/AuthController.cs index 814d752..b4a55b9 100644 --- a/SPMS.API/Controllers/AuthController.cs +++ b/SPMS.API/Controllers/AuthController.cs @@ -99,13 +99,28 @@ public class AuthController : ControllerBase [AllowAnonymous] [SwaggerOperation( Summary = "이메일 인증", - Description = "회원가입 시 발송된 인증 코드로 이메일을 인증합니다.")] - [SwaggerResponse(200, "이메일 인증 성공")] + Description = "인증 코드로 이메일을 인증합니다. verify_session_id(권장) 또는 email로 대상을 지정합니다. 5회 실패 시 30분간 차단됩니다.")] + [SwaggerResponse(200, "이메일 인증 성공", typeof(ApiResponse))] [SwaggerResponse(400, "인증 코드 불일치 또는 만료")] + [SwaggerResponse(429, "시도 횟수 초과")] public async Task VerifyEmailAsync([FromBody] EmailVerifyRequestDto request) { - await _authService.VerifyEmailAsync(request); - return Ok(ApiResponse.Success()); + var result = await _authService.VerifyEmailAsync(request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("email/verify/resend")] + [AllowAnonymous] + [SwaggerOperation( + Summary = "이메일 인증코드 재전송", + Description = "인증 세션 ID로 인증코드를 재전송합니다. 재전송 간 60초 쿨다운이 적용되며, 인증코드 유효시간은 5분입니다.")] + [SwaggerResponse(200, "재전송 성공", typeof(ApiResponse))] + [SwaggerResponse(400, "유효하지 않은 세션")] + [SwaggerResponse(429, "재전송 쿨다운 중")] + public async Task ResendVerificationAsync([FromBody] EmailResendRequestDto request) + { + var result = await _authService.ResendVerificationAsync(request); + return Ok(ApiResponse.Success(result)); } [HttpPost("password/change")] diff --git a/SPMS.Application/DTOs/Auth/EmailResendRequestDto.cs b/SPMS.Application/DTOs/Auth/EmailResendRequestDto.cs new file mode 100644 index 0000000..54e9b94 --- /dev/null +++ b/SPMS.Application/DTOs/Auth/EmailResendRequestDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Auth; + +public class EmailResendRequestDto +{ + [Required(ErrorMessage = "인증 세션 ID는 필수입니다.")] + [JsonPropertyName("verify_session_id")] + public string VerifySessionId { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/Auth/EmailResendResponseDto.cs b/SPMS.Application/DTOs/Auth/EmailResendResponseDto.cs new file mode 100644 index 0000000..4067900 --- /dev/null +++ b/SPMS.Application/DTOs/Auth/EmailResendResponseDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Auth; + +public class EmailResendResponseDto +{ + [JsonPropertyName("resent")] + public bool Resent { get; set; } + + [JsonPropertyName("cooldown_seconds")] + public int CooldownSeconds { get; set; } + + [JsonPropertyName("expires_in_seconds")] + public int ExpiresInSeconds { get; set; } +} diff --git a/SPMS.Application/DTOs/Auth/EmailVerifyRequestDto.cs b/SPMS.Application/DTOs/Auth/EmailVerifyRequestDto.cs index 99155f9..2170e6e 100644 --- a/SPMS.Application/DTOs/Auth/EmailVerifyRequestDto.cs +++ b/SPMS.Application/DTOs/Auth/EmailVerifyRequestDto.cs @@ -1,13 +1,18 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; namespace SPMS.Application.DTOs.Auth; public class EmailVerifyRequestDto { - [Required(ErrorMessage = "이메일은 필수입니다.")] + [JsonPropertyName("verify_session_id")] + public string? VerifySessionId { get; set; } + [EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")] - public string Email { get; set; } = string.Empty; + [JsonPropertyName("email")] + public string? Email { get; set; } [Required(ErrorMessage = "인증 코드는 필수입니다.")] + [JsonPropertyName("code")] public string Code { get; set; } = string.Empty; } diff --git a/SPMS.Application/DTOs/Auth/EmailVerifyResponseDto.cs b/SPMS.Application/DTOs/Auth/EmailVerifyResponseDto.cs new file mode 100644 index 0000000..31b6d32 --- /dev/null +++ b/SPMS.Application/DTOs/Auth/EmailVerifyResponseDto.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Auth; + +public class EmailVerifyResponseDto +{ + [JsonPropertyName("verified")] + public bool Verified { get; set; } + + [JsonPropertyName("next_action")] + public string NextAction { get; set; } = string.Empty; +} diff --git a/SPMS.Application/Interfaces/IAuthService.cs b/SPMS.Application/Interfaces/IAuthService.cs index edfbba8..848eaf7 100644 --- a/SPMS.Application/Interfaces/IAuthService.cs +++ b/SPMS.Application/Interfaces/IAuthService.cs @@ -11,7 +11,8 @@ public interface IAuthService Task LogoutAsync(long adminId, string accessToken); Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request); Task CheckEmailAsync(EmailCheckRequestDto request); - Task VerifyEmailAsync(EmailVerifyRequestDto request); + Task VerifyEmailAsync(EmailVerifyRequestDto request); + Task ResendVerificationAsync(EmailResendRequestDto request); Task ForgotPasswordAsync(PasswordForgotRequestDto request); Task ResetPasswordAsync(PasswordResetRequestDto request); Task GetProfileAsync(long adminId); diff --git a/SPMS.Application/Services/AuthService.cs b/SPMS.Application/Services/AuthService.cs index 9c509cc..686aa02 100644 --- a/SPMS.Application/Services/AuthService.cs +++ b/SPMS.Application/Services/AuthService.cs @@ -77,12 +77,12 @@ public class AuthService : IAuthService await _adminRepository.AddAsync(admin); await _unitOfWork.SaveChangesAsync(); - // 6. 이메일 인증 코드 생성/저장 + // 6. 이메일 인증 코드 생성/저장 (5분 TTL) var verificationCode = Random.Shared.Next(100000, 999999).ToString(); await _tokenStore.StoreAsync( $"email_verify:{request.Email}", verificationCode, - TimeSpan.FromHours(1)); + TimeSpan.FromMinutes(5)); // 7. Verify Session 생성 (sessionId → email 매핑) var verifySessionId = Guid.NewGuid().ToString("N"); @@ -164,12 +164,12 @@ public class AuthService : IAuthService { nextAction = "VERIFY_EMAIL"; - // 인증코드 생성/저장 + // 인증코드 생성/저장 (5분 TTL) var verificationCode = Random.Shared.Next(100000, 999999).ToString(); await _tokenStore.StoreAsync( $"email_verify:{admin.Email}", verificationCode, - TimeSpan.FromHours(1)); + TimeSpan.FromMinutes(5)); // Verify Session 생성 verifySessionId = Guid.NewGuid().ToString("N"); @@ -317,40 +317,105 @@ public class AuthService : IAuthService }; } - public async Task VerifyEmailAsync(EmailVerifyRequestDto request) + public async Task VerifyEmailAsync(EmailVerifyRequestDto request) { - var admin = await _adminRepository.GetByEmailAsync(request.Email); - if (admin is null) + // 1. 이메일 결정: verifySessionId 우선, email 하위호환 + string? email = null; + if (!string.IsNullOrEmpty(request.VerifySessionId)) { - throw new SpmsException( - ErrorCodes.VerificationCodeError, - "인증 코드가 유효하지 않습니다.", - 400); + email = await _tokenStore.GetAsync($"verify_session:{request.VerifySessionId}"); + if (email is null) + throw new SpmsException(ErrorCodes.VerificationCodeError, "인증 세션이 만료되었거나 유효하지 않습니다.", 400); } + else if (!string.IsNullOrEmpty(request.Email)) + { + email = request.Email; + } + else + { + throw new SpmsException(ErrorCodes.BadRequest, "verify_session_id 또는 email 중 하나는 필수입니다.", 400); + } + + // 2. 시도 횟수 체크 (최대 5회, 30분 TTL) + var attemptsKey = $"verify_attempts:{email}"; + var attemptsStr = await _tokenStore.GetAsync(attemptsKey); + var attempts = int.TryParse(attemptsStr, out var a) ? a : 0; + if (attempts >= 5) + throw new SpmsException(ErrorCodes.VerifyAttemptExceeded, "인증 시도 횟수를 초과했습니다. 30분 후 다시 시도해주세요.", 429); + + // 3. 관리자 조회 + var admin = await _adminRepository.GetByEmailAsync(email); + if (admin is null) + throw new SpmsException(ErrorCodes.VerificationCodeError, "인증 코드가 유효하지 않습니다.", 400); if (admin.EmailVerified) - { - throw new SpmsException( - ErrorCodes.VerificationCodeError, - "이미 인증된 이메일입니다.", - 400); - } + throw new SpmsException(ErrorCodes.VerificationCodeError, "이미 인증된 이메일입니다.", 400); - var storedCode = await _tokenStore.GetAsync($"email_verify:{request.Email}"); + // 4. 인증코드 검증 + var storedCode = await _tokenStore.GetAsync($"email_verify:{email}"); if (storedCode is null || storedCode != request.Code) { - throw new SpmsException( - ErrorCodes.VerificationCodeError, - "인증 코드가 유효하지 않거나 만료되었습니다.", - 400); + // 시도 횟수 증가 + await _tokenStore.StoreAsync(attemptsKey, (attempts + 1).ToString(), TimeSpan.FromMinutes(30)); + throw new SpmsException(ErrorCodes.VerificationCodeError, "인증 코드가 유효하지 않거나 만료되었습니다.", 400); } + // 5. 인증 성공 처리 admin.EmailVerified = true; admin.EmailVerifiedAt = DateTime.UtcNow; _adminRepository.Update(admin); await _unitOfWork.SaveChangesAsync(); - await _tokenStore.RemoveAsync($"email_verify:{request.Email}"); + // 6. 관련 Redis 키 정리 + await _tokenStore.RemoveAsync($"email_verify:{email}"); + await _tokenStore.RemoveAsync(attemptsKey); + if (!string.IsNullOrEmpty(request.VerifySessionId)) + await _tokenStore.RemoveAsync($"verify_session:{request.VerifySessionId}"); + + return new EmailVerifyResponseDto + { + Verified = true, + NextAction = "GO_LOGIN" + }; + } + + public async Task ResendVerificationAsync(EmailResendRequestDto request) + { + // 1. 세션에서 이메일 해석 + var email = await _tokenStore.GetAsync($"verify_session:{request.VerifySessionId}"); + if (email is null) + throw new SpmsException(ErrorCodes.VerificationCodeError, "인증 세션이 만료되었거나 유효하지 않습니다.", 400); + + // 2. 쿨다운 체크 (60초) + var cooldownKey = $"resend_cooldown:{email}"; + var cooldownExists = await _tokenStore.GetAsync(cooldownKey); + if (cooldownExists is not null) + throw new SpmsException(ErrorCodes.VerifyResendCooldown, "재전송 대기 시간입니다. 잠시 후 다시 시도해주세요.", 429); + + // 3. 이미 인증된 계정인지 확인 + var admin = await _adminRepository.GetByEmailAsync(email); + if (admin is null) + throw new SpmsException(ErrorCodes.VerificationCodeError, "유효하지 않은 인증 세션입니다.", 400); + + if (admin.EmailVerified) + throw new SpmsException(ErrorCodes.VerificationCodeError, "이미 인증된 이메일입니다.", 400); + + // 4. 새 인증코드 생성 → 기존 코드 덮어씀 (5분 TTL) + var verificationCode = Random.Shared.Next(100000, 999999).ToString(); + await _tokenStore.StoreAsync($"email_verify:{email}", verificationCode, TimeSpan.FromMinutes(5)); + + // 5. 쿨다운 키 설정 (60초) + await _tokenStore.StoreAsync(cooldownKey, "1", TimeSpan.FromSeconds(60)); + + // 6. 메일 발송 + await _emailService.SendVerificationCodeAsync(email, verificationCode); + + return new EmailResendResponseDto + { + Resent = true, + CooldownSeconds = 60, + ExpiresInSeconds = 300 + }; } public async Task ForgotPasswordAsync(PasswordForgotRequestDto request) diff --git a/SPMS.Domain/Common/ErrorCodes.cs b/SPMS.Domain/Common/ErrorCodes.cs index 3e8e2a9..6e3dff9 100644 --- a/SPMS.Domain/Common/ErrorCodes.cs +++ b/SPMS.Domain/Common/ErrorCodes.cs @@ -25,6 +25,8 @@ public static class ErrorCodes public const string LoginAttemptExceeded = "113"; public const string TermsNotAgreed = "114"; public const string PrivacyNotAgreed = "115"; + public const string VerifyResendCooldown = "116"; + public const string VerifyAttemptExceeded = "117"; // === Account (2) === public const string PasswordValidationFailed = "121"; -- 2.45.1