diff --git a/src/main/kotlin/uoslife/servermeeting/admin/api/AdminApi.kt b/src/main/kotlin/uoslife/servermeeting/admin/api/AdminApi.kt index 8f39eb13..8cb78a23 100644 --- a/src/main/kotlin/uoslife/servermeeting/admin/api/AdminApi.kt +++ b/src/main/kotlin/uoslife/servermeeting/admin/api/AdminApi.kt @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -17,6 +18,7 @@ import uoslife.servermeeting.admin.dto.request.ResetEmailRequest import uoslife.servermeeting.admin.service.AdminService import uoslife.servermeeting.global.config.SwaggerConfig import uoslife.servermeeting.global.error.ErrorResponse +import uoslife.servermeeting.global.util.RequestUtils import uoslife.servermeeting.payment.dto.response.PaymentResponseDto @RestController @@ -50,7 +52,10 @@ import uoslife.servermeeting.payment.dto.response.PaymentResponseDto )] )] ) -class AdminApi(private val adminService: AdminService) { +class AdminApi( + private val adminService: AdminService, + private val requestUtils: RequestUtils, +) { @Operation(summary = "이메일 발송 횟수 초기화", description = "특정 이메일의 일일 발송 횟수를 초기화합니다.") @ApiResponses( value = @@ -63,8 +68,12 @@ class AdminApi(private val adminService: AdminService) { ] ) @PostMapping("/verification/send-email/reset") - fun resetEmailSendCount(@RequestBody request: ResetEmailRequest): ResponseEntity { - adminService.resetEmailSendCount(request.email) + fun resetEmailSendCount( + request: HttpServletRequest, + @RequestBody body: ResetEmailRequest + ): ResponseEntity { + val requestInfo = requestUtils.toRequestInfoDto(request) + adminService.resetEmailSendCount(body.email, requestInfo) return ResponseEntity.status(HttpStatus.NO_CONTENT).build() } @@ -95,10 +104,12 @@ class AdminApi(private val adminService: AdminService) { ) @DeleteMapping("/user") fun deleteUser( - @RequestBody request: DeleteUserRequest, + @RequestBody body: DeleteUserRequest, + request: HttpServletRequest, response: HttpServletResponse ): ResponseEntity { - adminService.deleteUserById(request.userId, response) + val requestInfo = requestUtils.toRequestInfoDto(request) + adminService.deleteUserById(body.userId, response, requestInfo) return ResponseEntity.status(HttpStatus.NO_CONTENT).build() } @@ -144,7 +155,10 @@ class AdminApi(private val adminService: AdminService) { ] ) @PostMapping("/refund/match") - fun refundPayment(): ResponseEntity { - return ResponseEntity.status(HttpStatus.OK).body(adminService.refundPayment()) + fun refundPayment( + request: HttpServletRequest + ): ResponseEntity { + val requestInfo = requestUtils.toRequestInfoDto(request) + return ResponseEntity.status(HttpStatus.OK).body(adminService.refundPayment(requestInfo)) } } diff --git a/src/main/kotlin/uoslife/servermeeting/admin/service/AdminService.kt b/src/main/kotlin/uoslife/servermeeting/admin/service/AdminService.kt index ea861edc..e87b208e 100644 --- a/src/main/kotlin/uoslife/servermeeting/admin/service/AdminService.kt +++ b/src/main/kotlin/uoslife/servermeeting/admin/service/AdminService.kt @@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Qualifier import org.springframework.data.redis.core.RedisTemplate import org.springframework.stereotype.Service +import uoslife.servermeeting.global.common.dto.RequestInfoDto import uoslife.servermeeting.payment.dto.response.PaymentResponseDto import uoslife.servermeeting.payment.service.PaymentService import uoslife.servermeeting.user.service.UserService @@ -22,21 +23,26 @@ class AdminService( private val logger = LoggerFactory.getLogger(AdminService::class.java) } - fun resetEmailSendCount(email: String) { + fun resetEmailSendCount(email: String, requestInfo: RequestInfoDto) { val sendCountKey = VerificationUtils.generateRedisKey(VerificationConstants.SEND_COUNT_PREFIX, email, true) redisTemplate.opsForValue().set(sendCountKey, "0") redisTemplate.expire(sendCountKey, Duration.ofDays(1)) - logger.info("[이메일 발송 횟수 초기화] email: $email") + logger.info("[ADMIN-이메일 발송 횟수 초기화] targetEmail: $email, $requestInfo") } - fun deleteUserById(userId: Long, response: HttpServletResponse) { + fun deleteUserById(userId: Long, response: HttpServletResponse, requestInfo: RequestInfoDto) { userService.deleteUserById(userId, response) + logger.info("[ADMIN-유저 삭제] targetUserId: $userId, $requestInfo") } - fun refundPayment(): PaymentResponseDto.NotMatchedPaymentRefundResponse { - return paymentService.refundPayment() + fun refundPayment( + requestInfo: RequestInfoDto + ): PaymentResponseDto.NotMatchedPaymentRefundResponse { + val result = paymentService.refundPayment() + logger.info("[ADMIN-매칭 실패 유저 환불] $requestInfo") + return result } } diff --git a/src/main/kotlin/uoslife/servermeeting/global/auth/service/AuthService.kt b/src/main/kotlin/uoslife/servermeeting/global/auth/service/AuthService.kt index 4620d719..b62ccdc8 100644 --- a/src/main/kotlin/uoslife/servermeeting/global/auth/service/AuthService.kt +++ b/src/main/kotlin/uoslife/servermeeting/global/auth/service/AuthService.kt @@ -12,11 +12,13 @@ import uoslife.servermeeting.global.auth.exception.* import uoslife.servermeeting.global.auth.security.JwtTokenProvider import uoslife.servermeeting.global.auth.security.SecurityConstants import uoslife.servermeeting.global.auth.util.CookieUtils +import uoslife.servermeeting.global.util.RequestUtils @Service class AuthService( private val jwtTokenProvider: JwtTokenProvider, private val cookieUtils: CookieUtils, + private val requestUtils: RequestUtils, @Value("\${jwt.refresh.expiration}") private val refreshTokenExpiration: Long, ) { companion object { @@ -43,15 +45,19 @@ class AuthService( } fun reissueTokens(request: HttpServletRequest, response: HttpServletResponse): JwtResponse { + val requestInfo = requestUtils.toRequestInfoDto(request) val refreshToken = cookieUtils.getRefreshTokenFromCookie(request) - ?: throw JwtRefreshTokenNotFoundException() - + ?: run { + logger.warn("[재발급 실패(토큰없음)] $requestInfo") + throw JwtRefreshTokenNotFoundException() + } try { val userId = jwtTokenProvider.getUserIdFromRefreshToken(refreshToken) val storedToken = jwtTokenProvider.getStoredRefreshToken(userId) if (storedToken != refreshToken) { + logger.warn("[재발급 실패(재사용)] $requestInfo") throw JwtRefreshTokenReusedException() } @@ -61,26 +67,30 @@ class AuthService( jwtTokenProvider.saveRefreshToken(userId, newRefreshToken) cookieUtils.addRefreshTokenCookie(response, newRefreshToken, refreshTokenExpiration) - logger.info("[토큰 재발급 성공] USER ID: $userId") + logger.info("[재발급 성공] userId: $userId") return JwtResponse(newAccessToken) } catch (e: ExpiredJwtException) { + logger.warn("[재발급 실패(만료)] $requestInfo") throw JwtRefreshTokenExpiredException() } catch (e: JwtException) { + logger.warn("[재발급 실패(서명)] $requestInfo") throw JwtTokenInvalidSignatureException() } } fun logout(request: HttpServletRequest, response: HttpServletResponse) { + val requestInfo = requestUtils.toRequestInfoDto(request) + val refreshToken = cookieUtils.getRefreshTokenFromCookie(request) if (refreshToken != null) { try { val userId = jwtTokenProvider.getUserIdFromRefreshToken(refreshToken) jwtTokenProvider.deleteRefreshToken(userId) } catch (e: JwtException) { - logger.warn("[로그아웃 요청] 유효하지 않은 리프레시 토큰으로 로그아웃 시도") + logger.warn("[로그아웃 요청] 유효하지 않은 리프레시 토큰 사용 $requestInfo") } } else { - logger.warn("[로그아웃 요청] 리프레시 토큰 없이 로그아웃 시도") + logger.warn("[로그아웃 요청] 리프레시 토큰 없음 $requestInfo") } cookieUtils.deleteRefreshTokenCookie(response) } diff --git a/src/main/kotlin/uoslife/servermeeting/global/common/dto/RequestInfoDto.kt b/src/main/kotlin/uoslife/servermeeting/global/common/dto/RequestInfoDto.kt new file mode 100644 index 00000000..682a72c1 --- /dev/null +++ b/src/main/kotlin/uoslife/servermeeting/global/common/dto/RequestInfoDto.kt @@ -0,0 +1,7 @@ +package uoslife.servermeeting.global.common.dto + +data class RequestInfoDto(val ip: String, val userAgent: String) { + override fun toString(): String { + return "ip: $ip, userAgent: $userAgent" + } +} diff --git a/src/main/kotlin/uoslife/servermeeting/global/util/RequestUtils.kt b/src/main/kotlin/uoslife/servermeeting/global/util/RequestUtils.kt new file mode 100644 index 00000000..cd216366 --- /dev/null +++ b/src/main/kotlin/uoslife/servermeeting/global/util/RequestUtils.kt @@ -0,0 +1,30 @@ +package uoslife.servermeeting.global.util + +import jakarta.servlet.http.HttpServletRequest +import org.springframework.stereotype.Component +import uoslife.servermeeting.global.common.dto.RequestInfoDto + +@Component +class RequestUtils { + companion object { + private const val X_FORWARDED_FOR = "X-Forwarded-For" + private const val PROXY_CLIENT_IP = "Proxy-Client-IP" + private const val WL_PROXY_CLIENT_IP = "WL-Proxy-Client-IP" + } + + fun toRequestInfoDto(request: HttpServletRequest) = + RequestInfoDto(ip = getClientIp(request), userAgent = getUserAgent(request)) + + fun getClientIp(request: HttpServletRequest): String = + when { + request.getHeader(X_FORWARDED_FOR) != null -> + request.getHeader(X_FORWARDED_FOR).split(",")[0] + request.getHeader(PROXY_CLIENT_IP) != null -> request.getHeader(PROXY_CLIENT_IP) + request.getHeader(WL_PROXY_CLIENT_IP) != null -> request.getHeader(WL_PROXY_CLIENT_IP) + else -> request.remoteAddr + } + + fun getUserAgent(request: HttpServletRequest): String { + return request.getHeader("User-Agent") ?: "Unknown" + } +} diff --git a/src/main/kotlin/uoslife/servermeeting/user/service/UserService.kt b/src/main/kotlin/uoslife/servermeeting/user/service/UserService.kt index f470c7af..8e702a8d 100644 --- a/src/main/kotlin/uoslife/servermeeting/user/service/UserService.kt +++ b/src/main/kotlin/uoslife/servermeeting/user/service/UserService.kt @@ -120,7 +120,7 @@ class UserService( deleteRefreshInfo(user, response) userRepository.delete(user) - logger.info("[유저 삭제 완료] User Email : $deletedEmail") + logger.info("[유저 삭제] email: $deletedEmail") } private fun deleteRefreshInfo(user: User, response: HttpServletResponse) { diff --git a/src/main/kotlin/uoslife/servermeeting/verification/api/VerificationApi.kt b/src/main/kotlin/uoslife/servermeeting/verification/api/VerificationApi.kt index 0586fe09..45cb9e15 100644 --- a/src/main/kotlin/uoslife/servermeeting/verification/api/VerificationApi.kt +++ b/src/main/kotlin/uoslife/servermeeting/verification/api/VerificationApi.kt @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity @@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.* import uoslife.servermeeting.global.auth.dto.response.JwtResponse import uoslife.servermeeting.global.auth.service.AuthService import uoslife.servermeeting.global.error.ErrorResponse +import uoslife.servermeeting.global.util.RequestUtils import uoslife.servermeeting.user.service.UserService import uoslife.servermeeting.verification.dto.request.VerifyEmailRequest import uoslife.servermeeting.verification.dto.response.SendVerificationEmailResponse @@ -26,6 +28,7 @@ class VerificationApi( private val emailVerificationService: EmailVerificationService, private val userService: UserService, private val authService: AuthService, + private val requestUtils: RequestUtils, ) { @Operation(summary = "인증메일 전송", description = "이메일로 인증코드를 전송합니다.") @ApiResponses( @@ -80,9 +83,11 @@ class VerificationApi( ) @PostMapping("/send-email") fun sendVerificationEmail( - @RequestParam email: String + @RequestParam email: String, + request: HttpServletRequest ): ResponseEntity { - val response = emailVerificationService.sendVerificationEmail(email) + val requestInfo = requestUtils.toRequestInfoDto(request) + val response = emailVerificationService.sendVerificationEmail(email, requestInfo) return ResponseEntity.ok(response) } @@ -180,16 +185,19 @@ class VerificationApi( // TODO: Service layer로 이동해야함 @PostMapping("/verify-email") fun verifyEmail( - @Valid @RequestBody request: VerifyEmailRequest, + @Valid @RequestBody body: VerifyEmailRequest, + @PathVariable userId: Long, + request: HttpServletRequest, response: HttpServletResponse ): ResponseEntity { - emailVerificationService.verifyEmail(request.email, request.code) + val requestInfo = requestUtils.toRequestInfoDto(request) + emailVerificationService.verifyEmail(body.email, body.code, requestInfo) val user = try { - userService.getUserByEmail(request.email) + userService.getUserByEmail(body.email) } catch (e: Exception) { - userService.createUserByEmail(request.email) + userService.createUserByEmail(body.email) } val accessToken = authService.issueTokens(user.id!!, response) diff --git a/src/main/kotlin/uoslife/servermeeting/verification/service/EmailVerificationService.kt b/src/main/kotlin/uoslife/servermeeting/verification/service/EmailVerificationService.kt index ec5a93a4..844ff2df 100644 --- a/src/main/kotlin/uoslife/servermeeting/verification/service/EmailVerificationService.kt +++ b/src/main/kotlin/uoslife/servermeeting/verification/service/EmailVerificationService.kt @@ -7,6 +7,7 @@ import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.data.redis.core.RedisTemplate import org.springframework.stereotype.Service +import uoslife.servermeeting.global.common.dto.RequestInfoDto import uoslife.servermeeting.verification.dto.response.SendVerificationEmailResponse import uoslife.servermeeting.verification.exception.* import uoslife.servermeeting.verification.util.VerificationConstants @@ -24,7 +25,10 @@ class EmailVerificationService( private val logger = LoggerFactory.getLogger(EmailVerificationService::class.java) } - fun sendVerificationEmail(email: String): SendVerificationEmailResponse { + fun sendVerificationEmail( + email: String, + requestInfo: RequestInfoDto + ): SendVerificationEmailResponse { // 이메일 형식 검증 validateEmail(email) // 발송 제한 확인 @@ -33,9 +37,9 @@ class EmailVerificationService( val asyncResult = asyncEmailService.sendEmailAsync(email) asyncResult.whenComplete { _, exception -> if (exception != null) { - logger.warn("[이메일 전송 실패] EMAIL : $email") + logger.warn("[이메일 전송 실패] email: $email, $requestInfo") } else { - logger.info("[이메일 전송 성공] EMAIL : $email") + logger.info("[이메일 전송 성공] email: $email, $requestInfo") } } // 코드 만료 시각 계산 @@ -47,13 +51,13 @@ class EmailVerificationService( ) } - fun verifyEmail(email: String, code: String) { + fun verifyEmail(email: String, code: String, requestInfo: RequestInfoDto) { validateVerificationAttempts(email) incrementVerificationAttempts(email) // Redis에서 인증 코드 조회 val redisCode = getVerificationCode(email) // 인증 코드 검증 - validateVerificationCode(redisCode, code) + validateVerificationCode(redisCode, code, email, requestInfo) // 검증 성공한 코드 삭제 clearVerificationData(email) } @@ -64,10 +68,17 @@ class EmailVerificationService( return redisTemplate.opsForValue().get(verificationCodeKey).toString() } - private fun validateVerificationCode(redisCode: String, code: String) { + private fun validateVerificationCode( + redisCode: String, + code: String, + email: String, + requestInfo: RequestInfoDto + ) { if (redisCode != code) { + logger.warn("[이메일 인증 실패] email: $email, $requestInfo") throw EmailVerificationCodeMismatchException() } + logger.info("[이메일 인증 성공] email: $email") } private fun validateSendCount(email: String) {