diff --git a/SPMS.API/Controllers/AuthController.cs b/SPMS.API/Controllers/AuthController.cs index b4a55b9..7eac9f4 100644 --- a/SPMS.API/Controllers/AuthController.cs +++ b/SPMS.API/Controllers/AuthController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Swashbuckle.AspNetCore.Annotations; using SPMS.Application.DTOs.Auth; using SPMS.Application.Interfaces; @@ -49,13 +50,15 @@ public class AuthController : ControllerBase [HttpPost("login")] [AllowAnonymous] + [EnableRateLimiting("auth_sensitive")] [SwaggerOperation( Summary = "관리자 로그인", Description = "이메일과 비밀번호로 로그인하여 JWT 토큰을 발급받습니다. " + - "응답의 nextAction으로 화면 분기: GO_DASHBOARD(대시보드), VERIFY_EMAIL(이메일 인증 필요). " + - "미인증 유저는 verifySessionId와 emailSent가 함께 반환됩니다.")] + "응답의 nextAction으로 화면 분기: GO_DASHBOARD(대시보드), VERIFY_EMAIL(이메일 인증 필요), CHANGE_PASSWORD(비밀번호 강제변경). " + + "5회 연속 실패 시 15분간 로그인이 차단됩니다.")] [SwaggerResponse(200, "로그인 성공", typeof(ApiResponse))] [SwaggerResponse(401, "로그인 실패")] + [SwaggerResponse(429, "로그인 시도 횟수 초과")] public async Task LoginAsync([FromBody] LoginRequestDto request) { var result = await _authService.LoginAsync(request); @@ -97,6 +100,7 @@ public class AuthController : ControllerBase [HttpPost("email/verify")] [AllowAnonymous] + [EnableRateLimiting("auth_sensitive")] [SwaggerOperation( Summary = "이메일 인증", Description = "인증 코드로 이메일을 인증합니다. verify_session_id(권장) 또는 email로 대상을 지정합니다. 5회 실패 시 30분간 차단됩니다.")] @@ -111,6 +115,7 @@ public class AuthController : ControllerBase [HttpPost("email/verify/resend")] [AllowAnonymous] + [EnableRateLimiting("auth_sensitive")] [SwaggerOperation( Summary = "이메일 인증코드 재전송", Description = "인증 세션 ID로 인증코드를 재전송합니다. 재전송 간 60초 쿨다운이 적용되며, 인증코드 유효시간은 5분입니다.")] diff --git a/SPMS.API/Controllers/PasswordController.cs b/SPMS.API/Controllers/PasswordController.cs index e05ea2f..c9ab2a7 100644 --- a/SPMS.API/Controllers/PasswordController.cs +++ b/SPMS.API/Controllers/PasswordController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Swashbuckle.AspNetCore.Annotations; using SPMS.Application.DTOs.Account; using SPMS.Application.Interfaces; @@ -21,6 +22,7 @@ public class PasswordController : ControllerBase } [HttpPost("forgot")] + [EnableRateLimiting("auth_sensitive")] [SwaggerOperation( Summary = "비밀번호 찾기", Description = "등록된 이메일로 비밀번호 재설정 토큰을 발송합니다. 보안을 위해 이메일 존재 여부와 관계없이 동일한 응답을 반환합니다.")] @@ -45,6 +47,7 @@ public class PasswordController : ControllerBase } [HttpPost("temp")] + [EnableRateLimiting("auth_sensitive")] [SwaggerOperation( Summary = "임시 비밀번호 발급", Description = "등록된 이메일로 임시 비밀번호를 발송합니다. 보안을 위해 이메일 존재 여부와 관계없이 동일한 응답을 반환합니다.")] diff --git a/SPMS.API/Program.cs b/SPMS.API/Program.cs index d9831c5..6312397 100644 --- a/SPMS.API/Program.cs +++ b/SPMS.API/Program.cs @@ -1,4 +1,5 @@ using System.Threading.RateLimiting; +using Microsoft.AspNetCore.RateLimiting; using Serilog; using SPMS.API.Extensions; using SPMS.Application; @@ -55,6 +56,11 @@ builder.Services.AddRateLimiter(options => ErrorCodes.LimitExceeded, "요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요."); await context.HttpContext.Response.WriteAsJsonAsync(response, cancellationToken); }; + options.AddFixedWindowLimiter("auth_sensitive", opt => + { + opt.PermitLimit = 20; + opt.Window = TimeSpan.FromMinutes(15); + }); options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => RateLimitPartition.GetFixedWindowLimiter( partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", diff --git a/SPMS.Application/SPMS.Application.csproj b/SPMS.Application/SPMS.Application.csproj index 4680c0c..66353a2 100644 --- a/SPMS.Application/SPMS.Application.csproj +++ b/SPMS.Application/SPMS.Application.csproj @@ -15,6 +15,7 @@ + diff --git a/SPMS.Application/Services/AuthService.cs b/SPMS.Application/Services/AuthService.cs index 456666b..e7118c8 100644 --- a/SPMS.Application/Services/AuthService.cs +++ b/SPMS.Application/Services/AuthService.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SPMS.Application.DTOs.Account; using SPMS.Application.DTOs.Auth; @@ -20,6 +21,7 @@ public class AuthService : IAuthService private readonly JwtSettings _jwtSettings; private readonly ITokenStore _tokenStore; private readonly IEmailService _emailService; + private readonly ILogger _logger; public AuthService( IAdminRepository adminRepository, @@ -27,7 +29,8 @@ public class AuthService : IAuthService IJwtService jwtService, IOptions jwtSettings, ITokenStore tokenStore, - IEmailService emailService) + IEmailService emailService, + ILogger logger) { _adminRepository = adminRepository; _unitOfWork = unitOfWork; @@ -35,6 +38,7 @@ public class AuthService : IAuthService _jwtSettings = jwtSettings.Value; _tokenStore = tokenStore; _emailService = emailService; + _logger = logger; } public async Task SignupAsync(SignupRequestDto request) @@ -115,10 +119,22 @@ public class AuthService : IAuthService public async Task 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, "이메일 또는 비밀번호가 일치하지 않습니다.", @@ -128,6 +144,8 @@ public class AuthService : IAuthService // 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, "이메일 또는 비밀번호가 일치하지 않습니다.", @@ -137,12 +155,17 @@ public class AuthService : IAuthService // 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, @@ -198,6 +221,7 @@ public class AuthService : IAuthService } // 7. 응답 반환 + _logger.LogInformation("[AUTH] 로그인 성공 — Email: {Email}", admin.Email); return new LoginResponseDto { AccessToken = accessToken, @@ -321,6 +345,7 @@ public class AuthService : IAuthService _adminRepository.Update(admin); await _unitOfWork.SaveChangesAsync(); + _logger.LogInformation("[AUTH] 비밀번호 변경 — AdminId: {AdminId}", adminId); } public async Task CheckEmailAsync(EmailCheckRequestDto request) @@ -357,7 +382,10 @@ public class AuthService : IAuthService 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); @@ -436,11 +464,24 @@ public class AuthService : IAuthService 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( @@ -448,6 +489,7 @@ public class AuthService : IAuthService 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) @@ -479,14 +521,30 @@ public class AuthService : IAuthService 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); @@ -500,6 +558,8 @@ public class AuthService : IAuthService // 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)