improvement: 이메일 인증/재전송 강화 (#205)
- 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
This commit is contained in:
parent
7155fb58dc
commit
3cc99c0284
|
|
@ -99,13 +99,28 @@ public class AuthController : ControllerBase
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[SwaggerOperation(
|
[SwaggerOperation(
|
||||||
Summary = "이메일 인증",
|
Summary = "이메일 인증",
|
||||||
Description = "회원가입 시 발송된 인증 코드로 이메일을 인증합니다.")]
|
Description = "인증 코드로 이메일을 인증합니다. verify_session_id(권장) 또는 email로 대상을 지정합니다. 5회 실패 시 30분간 차단됩니다.")]
|
||||||
[SwaggerResponse(200, "이메일 인증 성공")]
|
[SwaggerResponse(200, "이메일 인증 성공", typeof(ApiResponse<EmailVerifyResponseDto>))]
|
||||||
[SwaggerResponse(400, "인증 코드 불일치 또는 만료")]
|
[SwaggerResponse(400, "인증 코드 불일치 또는 만료")]
|
||||||
|
[SwaggerResponse(429, "시도 횟수 초과")]
|
||||||
public async Task<IActionResult> VerifyEmailAsync([FromBody] EmailVerifyRequestDto request)
|
public async Task<IActionResult> VerifyEmailAsync([FromBody] EmailVerifyRequestDto request)
|
||||||
{
|
{
|
||||||
await _authService.VerifyEmailAsync(request);
|
var result = await _authService.VerifyEmailAsync(request);
|
||||||
return Ok(ApiResponse.Success());
|
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")]
|
[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.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace SPMS.Application.DTOs.Auth;
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
public class EmailVerifyRequestDto
|
public class EmailVerifyRequestDto
|
||||||
{
|
{
|
||||||
[Required(ErrorMessage = "이메일은 필수입니다.")]
|
[JsonPropertyName("verify_session_id")]
|
||||||
|
public string? VerifySessionId { get; set; }
|
||||||
|
|
||||||
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
|
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
|
||||||
public string Email { get; set; } = string.Empty;
|
[JsonPropertyName("email")]
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
[Required(ErrorMessage = "인증 코드는 필수입니다.")]
|
[Required(ErrorMessage = "인증 코드는 필수입니다.")]
|
||||||
|
[JsonPropertyName("code")]
|
||||||
public string Code { get; set; } = string.Empty;
|
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 LogoutAsync(long adminId, string accessToken);
|
||||||
Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request);
|
Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request);
|
||||||
Task<EmailCheckResponseDto> CheckEmailAsync(EmailCheckRequestDto 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 ForgotPasswordAsync(PasswordForgotRequestDto request);
|
||||||
Task ResetPasswordAsync(PasswordResetRequestDto request);
|
Task ResetPasswordAsync(PasswordResetRequestDto request);
|
||||||
Task<ProfileResponseDto> GetProfileAsync(long adminId);
|
Task<ProfileResponseDto> GetProfileAsync(long adminId);
|
||||||
|
|
|
||||||
|
|
@ -77,12 +77,12 @@ public class AuthService : IAuthService
|
||||||
await _adminRepository.AddAsync(admin);
|
await _adminRepository.AddAsync(admin);
|
||||||
await _unitOfWork.SaveChangesAsync();
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
|
||||||
// 6. 이메일 인증 코드 생성/저장
|
// 6. 이메일 인증 코드 생성/저장 (5분 TTL)
|
||||||
var verificationCode = Random.Shared.Next(100000, 999999).ToString();
|
var verificationCode = Random.Shared.Next(100000, 999999).ToString();
|
||||||
await _tokenStore.StoreAsync(
|
await _tokenStore.StoreAsync(
|
||||||
$"email_verify:{request.Email}",
|
$"email_verify:{request.Email}",
|
||||||
verificationCode,
|
verificationCode,
|
||||||
TimeSpan.FromHours(1));
|
TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
// 7. Verify Session 생성 (sessionId → email 매핑)
|
// 7. Verify Session 생성 (sessionId → email 매핑)
|
||||||
var verifySessionId = Guid.NewGuid().ToString("N");
|
var verifySessionId = Guid.NewGuid().ToString("N");
|
||||||
|
|
@ -164,12 +164,12 @@ public class AuthService : IAuthService
|
||||||
{
|
{
|
||||||
nextAction = "VERIFY_EMAIL";
|
nextAction = "VERIFY_EMAIL";
|
||||||
|
|
||||||
// 인증코드 생성/저장
|
// 인증코드 생성/저장 (5분 TTL)
|
||||||
var verificationCode = Random.Shared.Next(100000, 999999).ToString();
|
var verificationCode = Random.Shared.Next(100000, 999999).ToString();
|
||||||
await _tokenStore.StoreAsync(
|
await _tokenStore.StoreAsync(
|
||||||
$"email_verify:{admin.Email}",
|
$"email_verify:{admin.Email}",
|
||||||
verificationCode,
|
verificationCode,
|
||||||
TimeSpan.FromHours(1));
|
TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
// Verify Session 생성
|
// Verify Session 생성
|
||||||
verifySessionId = Guid.NewGuid().ToString("N");
|
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);
|
// 1. 이메일 결정: verifySessionId 우선, email 하위호환
|
||||||
if (admin is null)
|
string? email = null;
|
||||||
|
if (!string.IsNullOrEmpty(request.VerifySessionId))
|
||||||
{
|
{
|
||||||
throw new SpmsException(
|
email = await _tokenStore.GetAsync($"verify_session:{request.VerifySessionId}");
|
||||||
ErrorCodes.VerificationCodeError,
|
if (email is null)
|
||||||
"인증 코드가 유효하지 않습니다.",
|
throw new SpmsException(ErrorCodes.VerificationCodeError, "인증 세션이 만료되었거나 유효하지 않습니다.", 400);
|
||||||
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)
|
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)
|
if (storedCode is null || storedCode != request.Code)
|
||||||
{
|
{
|
||||||
throw new SpmsException(
|
// 시도 횟수 증가
|
||||||
ErrorCodes.VerificationCodeError,
|
await _tokenStore.StoreAsync(attemptsKey, (attempts + 1).ToString(), TimeSpan.FromMinutes(30));
|
||||||
"인증 코드가 유효하지 않거나 만료되었습니다.",
|
throw new SpmsException(ErrorCodes.VerificationCodeError, "인증 코드가 유효하지 않거나 만료되었습니다.", 400);
|
||||||
400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. 인증 성공 처리
|
||||||
admin.EmailVerified = true;
|
admin.EmailVerified = true;
|
||||||
admin.EmailVerifiedAt = DateTime.UtcNow;
|
admin.EmailVerifiedAt = DateTime.UtcNow;
|
||||||
_adminRepository.Update(admin);
|
_adminRepository.Update(admin);
|
||||||
await _unitOfWork.SaveChangesAsync();
|
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)
|
public async Task ForgotPasswordAsync(PasswordForgotRequestDto request)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ public static class ErrorCodes
|
||||||
public const string LoginAttemptExceeded = "113";
|
public const string LoginAttemptExceeded = "113";
|
||||||
public const string TermsNotAgreed = "114";
|
public const string TermsNotAgreed = "114";
|
||||||
public const string PrivacyNotAgreed = "115";
|
public const string PrivacyNotAgreed = "115";
|
||||||
|
public const string VerifyResendCooldown = "116";
|
||||||
|
public const string VerifyAttemptExceeded = "117";
|
||||||
|
|
||||||
// === Account (2) ===
|
// === Account (2) ===
|
||||||
public const string PasswordValidationFailed = "121";
|
public const string PasswordValidationFailed = "121";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user