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, ITokenStore tokenStore, IEmailService emailService) { _adminRepository = adminRepository; _unitOfWork = unitOfWork; _jwtService = jwtService; _jwtSettings = jwtSettings.Value; _tokenStore = tokenStore; _emailService = emailService; } public async Task 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 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 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 CheckEmailAsync(EmailCheckRequestDto request) { var exists = await _adminRepository.EmailExistsAsync(request.Email); return new EmailCheckResponseDto { Email = request.Email, IsAvailable = !exists }; } public async Task 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 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 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 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 }; } }