improvement: 이메일 인증/재전송 강화 (#205)
All checks were successful
SPMS_API/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/206
This commit is contained in:
김선규 2026-02-25 01:40:55 +00:00
commit b6008fb657
8 changed files with 156 additions and 30 deletions

View File

@ -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<EmailVerifyResponseDto>))]
[SwaggerResponse(400, "인증 코드 불일치 또는 만료")]
[SwaggerResponse(429, "시도 횟수 초과")]
public async Task<IActionResult> VerifyEmailAsync([FromBody] EmailVerifyRequestDto request)
{
await _authService.VerifyEmailAsync(request);
return Ok(ApiResponse.Success());
var result = await _authService.VerifyEmailAsync(request);
return Ok(ApiResponse<EmailVerifyResponseDto>.Success(result));
}
[HttpPost("email/verify/resend")]
[AllowAnonymous]
[SwaggerOperation(
Summary = "이메일 인증코드 재전송",
Description = "인증 세션 ID로 인증코드를 재전송합니다. 재전송 간 60초 쿨다운이 적용되며, 인증코드 유효시간은 5분입니다.")]
[SwaggerResponse(200, "재전송 성공", typeof(ApiResponse<EmailResendResponseDto>))]
[SwaggerResponse(400, "유효하지 않은 세션")]
[SwaggerResponse(429, "재전송 쿨다운 중")]
public async Task<IActionResult> ResendVerificationAsync([FromBody] EmailResendRequestDto request)
{
var result = await _authService.ResendVerificationAsync(request);
return Ok(ApiResponse<EmailResendResponseDto>.Success(result));
}
[HttpPost("password/change")]

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,8 @@ public interface IAuthService
Task LogoutAsync(long adminId, string accessToken);
Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request);
Task<EmailCheckResponseDto> CheckEmailAsync(EmailCheckRequestDto request);
Task VerifyEmailAsync(EmailVerifyRequestDto request);
Task<EmailVerifyResponseDto> VerifyEmailAsync(EmailVerifyRequestDto request);
Task<EmailResendResponseDto> ResendVerificationAsync(EmailResendRequestDto request);
Task ForgotPasswordAsync(PasswordForgotRequestDto request);
Task ResetPasswordAsync(PasswordResetRequestDto request);
Task<ProfileResponseDto> GetProfileAsync(long adminId);

View File

@ -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<EmailVerifyResponseDto> 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<EmailResendResponseDto> 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)

View File

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