improvement: 이메일 인증/재전송 강화 (#205) #206
|
|
@ -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")]
|
||||
|
|
|
|||
11
SPMS.Application/DTOs/Auth/EmailResendRequestDto.cs
Normal file
11
SPMS.Application/DTOs/Auth/EmailResendRequestDto.cs
Normal 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;
|
||||
}
|
||||
15
SPMS.Application/DTOs/Auth/EmailResendResponseDto.cs
Normal file
15
SPMS.Application/DTOs/Auth/EmailResendResponseDto.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
12
SPMS.Application/DTOs/Auth/EmailVerifyResponseDto.cs
Normal file
12
SPMS.Application/DTOs/Auth/EmailVerifyResponseDto.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user