- Admin 엔티티에 Organization 컬럼 추가 + Migration - ProfileResponseDto에 last_login_at, organization 필드 추가 - UpdateProfileRequestDto에 organization 필드 추가 - AuthService 프로필 조회/수정 매핑 확장 - 활동 내역 DTO 및 GetActivityListAsync 메서드 추가 - ProfileController 활동 내역 조회 엔드포인트 추가 Closes #249
716 lines
26 KiB
C#
716 lines
26 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using SPMS.Application.DTOs.Account;
|
|
using SPMS.Application.DTOs.Auth;
|
|
using SPMS.Application.Interfaces;
|
|
using SPMS.Application.Settings;
|
|
using System.Security.Cryptography;
|
|
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;
|
|
private readonly IRepository<SystemLog> _systemLogRepository;
|
|
private readonly ILogger<AuthService> _logger;
|
|
|
|
public AuthService(
|
|
IAdminRepository adminRepository,
|
|
IUnitOfWork unitOfWork,
|
|
IJwtService jwtService,
|
|
IOptions<JwtSettings> jwtSettings,
|
|
ITokenStore tokenStore,
|
|
IEmailService emailService,
|
|
IRepository<SystemLog> systemLogRepository,
|
|
ILogger<AuthService> logger)
|
|
{
|
|
_adminRepository = adminRepository;
|
|
_unitOfWork = unitOfWork;
|
|
_jwtService = jwtService;
|
|
_jwtSettings = jwtSettings.Value;
|
|
_tokenStore = tokenStore;
|
|
_emailService = emailService;
|
|
_systemLogRepository = systemLogRepository;
|
|
_logger = logger;
|
|
}
|
|
|
|
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)
|
|
{
|
|
// 0. 로그인 시도 횟수 확인 (5회/15분)
|
|
var loginAttemptsKey = $"login_attempts:{request.Email}";
|
|
var loginAttemptsStr = await _tokenStore.GetAsync(loginAttemptsKey);
|
|
var loginAttempts = int.TryParse(loginAttemptsStr, out var la) ? la : 0;
|
|
if (loginAttempts >= 5)
|
|
{
|
|
_logger.LogWarning("[AUTH] 로그인 잠금 — Email: {Email}", request.Email);
|
|
throw new SpmsException(ErrorCodes.LoginAttemptExceeded, "로그인 시도 횟수를 초과했습니다. 15분 후 다시 시도해주세요.", 429);
|
|
}
|
|
|
|
// 1. 이메일로 관리자 조회
|
|
var admin = await _adminRepository.GetByEmailAsync(request.Email);
|
|
if (admin is null)
|
|
{
|
|
await _tokenStore.StoreAsync(loginAttemptsKey, (loginAttempts + 1).ToString(), TimeSpan.FromMinutes(15));
|
|
_logger.LogWarning("[AUTH] 로그인 실패 — Email: {Email}, Attempts: {Count}/5", request.Email, loginAttempts + 1);
|
|
throw new SpmsException(
|
|
ErrorCodes.LoginFailed,
|
|
"이메일 또는 비밀번호가 일치하지 않습니다.",
|
|
401);
|
|
}
|
|
|
|
// 2. 삭제된 계정 확인
|
|
if (admin.IsDeleted)
|
|
{
|
|
await _tokenStore.StoreAsync(loginAttemptsKey, (loginAttempts + 1).ToString(), TimeSpan.FromMinutes(15));
|
|
_logger.LogWarning("[AUTH] 로그인 실패 — Email: {Email}, Attempts: {Count}/5", request.Email, loginAttempts + 1);
|
|
throw new SpmsException(
|
|
ErrorCodes.LoginFailed,
|
|
"이메일 또는 비밀번호가 일치하지 않습니다.",
|
|
401);
|
|
}
|
|
|
|
// 3. 비밀번호 검증 (BCrypt)
|
|
if (!BCrypt.Net.BCrypt.Verify(request.Password, admin.Password))
|
|
{
|
|
await _tokenStore.StoreAsync(loginAttemptsKey, (loginAttempts + 1).ToString(), TimeSpan.FromMinutes(15));
|
|
_logger.LogWarning("[AUTH] 로그인 실패 — Email: {Email}, Attempts: {Count}/5", request.Email, loginAttempts + 1);
|
|
throw new SpmsException(
|
|
ErrorCodes.LoginFailed,
|
|
"이메일 또는 비밀번호가 일치하지 않습니다.",
|
|
401);
|
|
}
|
|
|
|
// 로그인 성공 — 시도 카운터 초기화
|
|
await _tokenStore.RemoveAsync(loginAttemptsKey);
|
|
|
|
// 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;
|
|
bool? mustChangePassword = 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;
|
|
}
|
|
}
|
|
else if (admin.MustChangePassword)
|
|
{
|
|
nextAction = "CHANGE_PASSWORD";
|
|
mustChangePassword = true;
|
|
}
|
|
|
|
// 7. 응답 반환
|
|
_logger.LogInformation("[AUTH] 로그인 성공 — Email: {Email}", admin.Email);
|
|
return new LoginResponseDto
|
|
{
|
|
AccessToken = accessToken,
|
|
RefreshToken = refreshToken,
|
|
ExpiresIn = _jwtSettings.ExpiryMinutes * 60,
|
|
NextAction = nextAction,
|
|
EmailVerified = admin.EmailVerified,
|
|
VerifySessionId = verifySessionId,
|
|
EmailSent = emailSent,
|
|
MustChangePassword = mustChangePassword,
|
|
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);
|
|
|
|
// 4. 강제변경 플래그 해제
|
|
if (admin.MustChangePassword)
|
|
{
|
|
admin.MustChangePassword = false;
|
|
admin.TempPasswordIssuedAt = null;
|
|
}
|
|
|
|
_adminRepository.Update(admin);
|
|
await _unitOfWork.SaveChangesAsync();
|
|
_logger.LogInformation("[AUTH] 비밀번호 변경 — AdminId: {AdminId}", adminId);
|
|
}
|
|
|
|
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)
|
|
{
|
|
_logger.LogWarning("[AUTH] 인증 시도 초과 — Email: {Email}", email);
|
|
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)
|
|
{
|
|
// 요청 제한 확인 (3회/1시간)
|
|
var forgotAttemptsKey = $"forgot_attempts:{request.Email}";
|
|
var forgotAttemptsStr = await _tokenStore.GetAsync(forgotAttemptsKey);
|
|
var forgotAttempts = int.TryParse(forgotAttemptsStr, out var fa) ? fa : 0;
|
|
if (forgotAttempts >= 3)
|
|
{
|
|
_logger.LogWarning("[AUTH] 비밀번호 찾기 제한 — Email: {Email}", request.Email);
|
|
return; // 계정 존재 비노출 원칙 유지
|
|
}
|
|
|
|
var admin = await _adminRepository.GetByEmailAsync(request.Email);
|
|
|
|
// 보안: 이메일 존재 여부와 관계없이 동일한 성공 응답 반환
|
|
if (admin is null)
|
|
{
|
|
await _tokenStore.StoreAsync(forgotAttemptsKey, (forgotAttempts + 1).ToString(), TimeSpan.FromHours(1));
|
|
return;
|
|
}
|
|
|
|
var resetToken = Guid.NewGuid().ToString("N");
|
|
await _tokenStore.StoreAsync(
|
|
$"password_reset:{request.Email}",
|
|
resetToken,
|
|
TimeSpan.FromMinutes(30));
|
|
await _emailService.SendPasswordResetTokenAsync(request.Email, resetToken);
|
|
await _tokenStore.StoreAsync(forgotAttemptsKey, (forgotAttempts + 1).ToString(), TimeSpan.FromHours(1));
|
|
}
|
|
|
|
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 IssueTempPasswordAsync(TempPasswordRequestDto request)
|
|
{
|
|
// 0. 요청 제한 확인 (3회/1시간)
|
|
var tempAttemptsKey = $"temp_password_attempts:{request.Email}";
|
|
var tempAttemptsStr = await _tokenStore.GetAsync(tempAttemptsKey);
|
|
var tempAttempts = int.TryParse(tempAttemptsStr, out var ta) ? ta : 0;
|
|
if (tempAttempts >= 3)
|
|
{
|
|
_logger.LogWarning("[AUTH] 임시 비밀번호 제한 — Email: {Email}", request.Email);
|
|
return; // 계정 존재 비노출 원칙 유지
|
|
}
|
|
|
|
// 1. 이메일로 관리자 조회 (없으면 조용히 반환 — 계정 존재 비노출)
|
|
var admin = await _adminRepository.GetByEmailAsync(request.Email);
|
|
if (admin is null)
|
|
{
|
|
await _tokenStore.StoreAsync(tempAttemptsKey, (tempAttempts + 1).ToString(), TimeSpan.FromHours(1));
|
|
return;
|
|
}
|
|
|
|
// 2. 삭제된 계정이면 조용히 반환
|
|
if (admin.IsDeleted)
|
|
{
|
|
await _tokenStore.StoreAsync(tempAttemptsKey, (tempAttempts + 1).ToString(), TimeSpan.FromHours(1));
|
|
return;
|
|
}
|
|
|
|
// 3. 임시 비밀번호 생성 (12자, 영대소+숫자+특수)
|
|
var tempPassword = GenerateTempPassword(12);
|
|
|
|
// 4. 해시 저장 + 강제변경 플래그 설정
|
|
admin.Password = BCrypt.Net.BCrypt.HashPassword(tempPassword);
|
|
admin.MustChangePassword = true;
|
|
admin.TempPasswordIssuedAt = DateTime.UtcNow;
|
|
_adminRepository.Update(admin);
|
|
await _unitOfWork.SaveChangesAsync();
|
|
|
|
// 5. 메일 발송
|
|
await _emailService.SendTempPasswordAsync(admin.Email, tempPassword);
|
|
await _tokenStore.StoreAsync(tempAttemptsKey, (tempAttempts + 1).ToString(), TimeSpan.FromHours(1));
|
|
_logger.LogInformation("[AUTH] 임시 비밀번호 발급 — Email: {Email}", admin.Email);
|
|
}
|
|
|
|
private static string GenerateTempPassword(int length)
|
|
{
|
|
const string upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
const string lower = "abcdefghijklmnopqrstuvwxyz";
|
|
const string digits = "0123456789";
|
|
const string special = "!@#$%^&*";
|
|
const string all = upper + lower + digits + special;
|
|
|
|
var password = new char[length];
|
|
|
|
// 각 카테고리에서 최소 1자씩
|
|
password[0] = upper[RandomNumberGenerator.GetInt32(upper.Length)];
|
|
password[1] = lower[RandomNumberGenerator.GetInt32(lower.Length)];
|
|
password[2] = digits[RandomNumberGenerator.GetInt32(digits.Length)];
|
|
password[3] = special[RandomNumberGenerator.GetInt32(special.Length)];
|
|
|
|
// 나머지 랜덤 채움
|
|
for (var i = 4; i < length; i++)
|
|
password[i] = all[RandomNumberGenerator.GetInt32(all.Length)];
|
|
|
|
// 셔플
|
|
for (var i = password.Length - 1; i > 0; i--)
|
|
{
|
|
var j = RandomNumberGenerator.GetInt32(i + 1);
|
|
(password[i], password[j]) = (password[j], password[i]);
|
|
}
|
|
|
|
return new string(password);
|
|
}
|
|
|
|
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,
|
|
LastLoginAt = admin.LastLoginAt,
|
|
Organization = admin.Organization
|
|
};
|
|
}
|
|
|
|
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 (request.Organization is not null && request.Organization != admin.Organization)
|
|
{
|
|
admin.Organization = request.Organization;
|
|
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,
|
|
LastLoginAt = admin.LastLoginAt,
|
|
Organization = admin.Organization
|
|
};
|
|
}
|
|
|
|
public async Task<ActivityListResponseDto> GetActivityListAsync(long adminId, ActivityListRequestDto request)
|
|
{
|
|
var page = request.Page > 0 ? request.Page : 1;
|
|
var size = request.Size > 0 ? request.Size : 10;
|
|
|
|
// 기간 필터 + AdminId 조건 조합
|
|
System.Linq.Expressions.Expression<Func<SystemLog, bool>> predicate = log =>
|
|
log.AdminId == adminId
|
|
&& (request.From == null || log.CreatedAt >= request.From.Value)
|
|
&& (request.To == null || log.CreatedAt <= request.To.Value);
|
|
|
|
var (items, totalCount) = await _systemLogRepository.GetPagedAsync(
|
|
page, size,
|
|
predicate,
|
|
log => log.CreatedAt,
|
|
descending: true);
|
|
|
|
var totalPages = (int)Math.Ceiling((double)totalCount / size);
|
|
|
|
return new ActivityListResponseDto
|
|
{
|
|
Items = items.Select(log => new ActivityItemDto
|
|
{
|
|
ActivityType = log.Action,
|
|
Title = log.TargetType ?? log.Action,
|
|
Description = log.Details,
|
|
IpAddress = log.IpAddress,
|
|
OccurredAt = log.CreatedAt
|
|
}).ToList(),
|
|
Pagination = new DTOs.Notice.PaginationDto
|
|
{
|
|
Page = page,
|
|
Size = size,
|
|
TotalCount = totalCount,
|
|
TotalPages = totalPages
|
|
}
|
|
};
|
|
}
|
|
}
|