SPMS_API/SPMS.Application/Services/AuthService.cs
SEAN 3cc99c0284 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
2026-02-25 10:38:41 +09:00

533 lines
18 KiB
C#

using Microsoft.Extensions.Options;
using SPMS.Application.DTOs.Account;
using SPMS.Application.DTOs.Auth;
using SPMS.Application.Interfaces;
using SPMS.Application.Settings;
using SPMS.Domain.Common;
using SPMS.Domain.Entities;
using SPMS.Domain.Enums;
using SPMS.Domain.Exceptions;
using SPMS.Domain.Interfaces;
namespace SPMS.Application.Services;
public class AuthService : IAuthService
{
private readonly IAdminRepository _adminRepository;
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,
ITokenStore tokenStore,
IEmailService emailService)
{
_adminRepository = adminRepository;
_unitOfWork = unitOfWork;
_jwtService = jwtService;
_jwtSettings = jwtSettings.Value;
_tokenStore = tokenStore;
_emailService = emailService;
}
public async Task<SignupResponseDto> SignupAsync(SignupRequestDto request)
{
// 1. 동의 검증
if (!request.AgreeTerms)
throw new SpmsException(ErrorCodes.TermsNotAgreed, "서비스 이용약관에 동의해야 합니다.", 400);
if (!request.AgreePrivacy)
throw new SpmsException(ErrorCodes.PrivacyNotAgreed, "개인정보 처리방침에 동의해야 합니다.", 400);
// 2. 이메일 중복 검사
if (await _adminRepository.EmailExistsAsync(request.Email))
{
throw new SpmsException(
ErrorCodes.Conflict,
"이미 사용 중인 이메일입니다.",
409);
}
// 3. AdminCode 생성 (UUID 12자)
var adminCode = Guid.NewGuid().ToString("N")[..12].ToUpper();
// 4. Admin 엔티티 생성 (동의 필드 포함)
var admin = new Admin
{
AdminCode = adminCode,
Email = request.Email,
Password = BCrypt.Net.BCrypt.HashPassword(request.Password),
Name = request.Name,
Phone = request.Phone,
Role = AdminRole.User,
EmailVerified = false,
CreatedAt = DateTime.UtcNow,
IsDeleted = false,
AgreeTerms = true,
AgreePrivacy = true,
AgreedAt = DateTime.UtcNow
};
// 5. 저장
await _adminRepository.AddAsync(admin);
await _unitOfWork.SaveChangesAsync();
// 6. 이메일 인증 코드 생성/저장 (5분 TTL)
var verificationCode = Random.Shared.Next(100000, 999999).ToString();
await _tokenStore.StoreAsync(
$"email_verify:{request.Email}",
verificationCode,
TimeSpan.FromMinutes(5));
// 7. Verify Session 생성 (sessionId → email 매핑)
var verifySessionId = Guid.NewGuid().ToString("N");
await _tokenStore.StoreAsync(
$"verify_session:{verifySessionId}",
request.Email,
TimeSpan.FromHours(1));
// 8. 메일 발송 (실패해도 가입은 유지)
var emailSent = true;
try
{
await _emailService.SendVerificationCodeAsync(request.Email, verificationCode);
}
catch
{
emailSent = false;
}
// 9. 응답 반환
return new SignupResponseDto
{
AdminCode = admin.AdminCode,
Email = admin.Email,
VerifySessionId = verifySessionId,
EmailSent = emailSent
};
}
public async Task<LoginResponseDto> LoginAsync(LoginRequestDto request)
{
// 1. 이메일로 관리자 조회
var admin = await _adminRepository.GetByEmailAsync(request.Email);
if (admin is null)
{
throw new SpmsException(
ErrorCodes.LoginFailed,
"이메일 또는 비밀번호가 일치하지 않습니다.",
401);
}
// 2. 삭제된 계정 확인
if (admin.IsDeleted)
{
throw new SpmsException(
ErrorCodes.LoginFailed,
"이메일 또는 비밀번호가 일치하지 않습니다.",
401);
}
// 3. 비밀번호 검증 (BCrypt)
if (!BCrypt.Net.BCrypt.Verify(request.Password, admin.Password))
{
throw new SpmsException(
ErrorCodes.LoginFailed,
"이메일 또는 비밀번호가 일치하지 않습니다.",
401);
}
// 4. 토큰 생성
var accessToken = _jwtService.GenerateAccessToken(
admin.Id,
admin.Role.ToString());
var refreshToken = _jwtService.GenerateRefreshToken();
// 5. Refresh Token 및 최종 로그인 시간 저장
admin.RefreshToken = refreshToken;
admin.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpiryDays);
admin.LastLoginAt = DateTime.UtcNow;
_adminRepository.Update(admin);
await _unitOfWork.SaveChangesAsync();
// 6. 분기 판정 + verify session 생성
var nextAction = "GO_DASHBOARD";
string? verifySessionId = null;
bool? emailSent = null;
if (!admin.EmailVerified)
{
nextAction = "VERIFY_EMAIL";
// 인증코드 생성/저장 (5분 TTL)
var verificationCode = Random.Shared.Next(100000, 999999).ToString();
await _tokenStore.StoreAsync(
$"email_verify:{admin.Email}",
verificationCode,
TimeSpan.FromMinutes(5));
// Verify Session 생성
verifySessionId = Guid.NewGuid().ToString("N");
await _tokenStore.StoreAsync(
$"verify_session:{verifySessionId}",
admin.Email,
TimeSpan.FromHours(1));
// 메일 발송 (실패해도 로그인은 유지)
emailSent = true;
try
{
await _emailService.SendVerificationCodeAsync(admin.Email, verificationCode);
}
catch
{
emailSent = false;
}
}
// 7. 응답 반환
return new LoginResponseDto
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = _jwtSettings.ExpiryMinutes * 60,
NextAction = nextAction,
EmailVerified = admin.EmailVerified,
VerifySessionId = verifySessionId,
EmailSent = emailSent,
Admin = new AdminInfoDto
{
AdminCode = admin.AdminCode,
Email = admin.Email,
Name = admin.Name,
Role = admin.Role.ToString()
}
};
}
public async Task<TokenRefreshResponseDto> RefreshTokenAsync(TokenRefreshRequestDto request)
{
// 1. Refresh Token으로 관리자 조회
var admin = await _adminRepository.GetByRefreshTokenAsync(request.RefreshToken);
if (admin is null)
{
throw new SpmsException(
ErrorCodes.Unauthorized,
"유효하지 않은 Refresh Token입니다.",
401);
}
// 2. Refresh Token 만료 확인
if (admin.RefreshTokenExpiresAt < DateTime.UtcNow)
{
throw new SpmsException(
ErrorCodes.Unauthorized,
"Refresh Token이 만료되었습니다. 다시 로그인해주세요.",
401);
}
// 3. 새 토큰 생성
var newAccessToken = _jwtService.GenerateAccessToken(
admin.Id,
admin.Role.ToString());
var newRefreshToken = _jwtService.GenerateRefreshToken();
// 4. 새 Refresh Token 저장 (Rotation)
admin.RefreshToken = newRefreshToken;
admin.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpiryDays);
_adminRepository.Update(admin);
await _unitOfWork.SaveChangesAsync();
// 5. 응답 반환
return new TokenRefreshResponseDto
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken,
ExpiresIn = _jwtSettings.ExpiryMinutes * 60
};
}
public async Task LogoutAsync(long adminId, string accessToken)
{
// 1. 관리자 조회
var admin = await _adminRepository.GetByIdAsync(adminId);
if (admin is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"관리자를 찾을 수 없습니다.",
404);
}
// 2. Refresh Token 무효화
admin.RefreshToken = null;
admin.RefreshTokenExpiresAt = null;
_adminRepository.Update(admin);
await _unitOfWork.SaveChangesAsync();
// 3. Access Token 블랙리스트 (JTI 기반, TTL = 남은 만료시간)
var (jti, validTo) = _jwtService.GetTokenInfo(accessToken);
if (!string.IsNullOrEmpty(jti))
{
var remaining = validTo - DateTime.UtcNow;
if (remaining > TimeSpan.Zero)
await _tokenStore.StoreAsync($"blacklist:{jti}", "revoked", remaining);
}
}
public async Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request)
{
// 1. 관리자 조회
var admin = await _adminRepository.GetByIdAsync(adminId);
if (admin is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"관리자를 찾을 수 없습니다.",
404);
}
// 2. 현재 비밀번호 검증
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, admin.Password))
{
throw new SpmsException(
ErrorCodes.PasswordValidationFailed,
"현재 비밀번호가 일치하지 않습니다.",
400);
}
// 3. 새 비밀번호 해싱 및 저장
admin.Password = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
_adminRepository.Update(admin);
await _unitOfWork.SaveChangesAsync();
}
public async Task<EmailCheckResponseDto> CheckEmailAsync(EmailCheckRequestDto request)
{
var exists = await _adminRepository.EmailExistsAsync(request.Email);
return new EmailCheckResponseDto
{
Email = request.Email,
IsAvailable = !exists
};
}
public async Task<EmailVerifyResponseDto> VerifyEmailAsync(EmailVerifyRequestDto request)
{
// 1. 이메일 결정: verifySessionId 우선, email 하위호환
string? email = null;
if (!string.IsNullOrEmpty(request.VerifySessionId))
{
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);
// 4. 인증코드 검증
var storedCode = await _tokenStore.GetAsync($"email_verify:{email}");
if (storedCode is null || storedCode != request.Code)
{
// 시도 횟수 증가
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();
// 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)
{
var admin = await _adminRepository.GetByEmailAsync(request.Email);
// 보안: 이메일 존재 여부와 관계없이 동일한 성공 응답 반환
if (admin is null)
return;
var resetToken = Guid.NewGuid().ToString("N");
await _tokenStore.StoreAsync(
$"password_reset:{request.Email}",
resetToken,
TimeSpan.FromMinutes(30));
await _emailService.SendPasswordResetTokenAsync(request.Email, resetToken);
}
public async Task ResetPasswordAsync(PasswordResetRequestDto request)
{
var storedToken = await _tokenStore.GetAsync($"password_reset:{request.Email}");
if (storedToken is null || storedToken != request.ResetToken)
{
throw new SpmsException(
ErrorCodes.ResetTokenError,
"재설정 토큰이 유효하지 않거나 만료되었습니다.",
400);
}
var admin = await _adminRepository.GetByEmailAsync(request.Email);
if (admin is null)
{
throw new SpmsException(
ErrorCodes.ResetTokenError,
"재설정 토큰이 유효하지 않거나 만료되었습니다.",
400);
}
admin.Password = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
_adminRepository.Update(admin);
await _unitOfWork.SaveChangesAsync();
await _tokenStore.RemoveAsync($"password_reset:{request.Email}");
}
public async Task<ProfileResponseDto> GetProfileAsync(long adminId)
{
var admin = await _adminRepository.GetByIdAsync(adminId);
if (admin is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"관리자를 찾을 수 없습니다.",
404);
}
return new ProfileResponseDto
{
AdminCode = admin.AdminCode,
Email = admin.Email,
Name = admin.Name,
Phone = admin.Phone,
Role = (int)admin.Role,
CreatedAt = admin.CreatedAt
};
}
public async Task<ProfileResponseDto> UpdateProfileAsync(long adminId, UpdateProfileRequestDto request)
{
var admin = await _adminRepository.GetByIdAsync(adminId);
if (admin is null)
{
throw new SpmsException(
ErrorCodes.NotFound,
"관리자를 찾을 수 없습니다.",
404);
}
var hasChange = false;
if (request.Name is not null && request.Name != admin.Name)
{
admin.Name = request.Name;
hasChange = true;
}
if (request.Phone is not null && request.Phone != admin.Phone)
{
admin.Phone = request.Phone;
hasChange = true;
}
if (!hasChange)
{
throw new SpmsException(
ErrorCodes.NoChange,
"변경된 내용이 없습니다.",
400);
}
_adminRepository.Update(admin);
await _unitOfWork.SaveChangesAsync();
return new ProfileResponseDto
{
AdminCode = admin.AdminCode,
Email = admin.Email,
Name = admin.Name,
Phone = admin.Phone,
Role = (int)admin.Role,
CreatedAt = admin.CreatedAt
};
}
}