From 0be5c3dc45840f08c1d6c4326125990f61f0d29a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Sat, 14 Jun 2025 19:36:08 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat(bucket4j)=20:=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 ++- .../exception/GlobalExceptionHandler.java | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/build.gradle b/build.gradle index ec99c8e..a0c8a75 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,7 @@ dependencies { // Spring Batch implementation 'org.springframework.boot:spring-boot-starter-batch' + implementation 'com.giffing.bucket4j.spring.boot.starter:bucket4j-spring-boot-starter:0.11.0' } //QueryDSL 초기 설정 @@ -101,4 +102,4 @@ configurations { tasks.named('test') { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/src/main/java/org/terning/terningserver/common/exception/GlobalExceptionHandler.java b/src/main/java/org/terning/terningserver/common/exception/GlobalExceptionHandler.java index 6242786..041161c 100644 --- a/src/main/java/org/terning/terningserver/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/terning/terningserver/common/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.core.NestedExceptionUtils; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.HttpRequestMethodNotSupportedException; @@ -20,6 +21,14 @@ @Slf4j public class GlobalExceptionHandler { + @ExceptionHandler(RateLimitException.class) + public ResponseEntity handleRateLimitException(RateLimitException e) { + log.warn("[RateLimitException] Too Many Requests, message: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.TOO_MANY_REQUESTS) + .body(ErrorResponse.of(HttpStatus.TOO_MANY_REQUESTS.value(), "요청 횟수가 너무 많습니다. 잠시 후 다시 시도해주세요.")); + } + @ExceptionHandler(CustomException.class) public ResponseEntity handleCustomException(CustomException e) { log.warn("[CustomException] cause: {}, message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage()); @@ -47,7 +56,7 @@ public ResponseEntity handleAuthException(JwtException e) { } @ExceptionHandler(Exception.class) - public ResponseEntity handleException(Exception e){ + public ResponseEntity handleException(Exception e){ log.warn("[Exception] cause: {} , message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage()); ErrorMessage errorCode = ErrorMessage.INTERNAL_SERVER_ERROR; return ResponseEntity @@ -55,9 +64,8 @@ public ResponseEntity handleException(Exception e){ .body(ErrorResponse.of(errorCode.getStatus(), errorCode.getMessage())); } - //메소드가 잘못되었거나 부적합한 인수를 전달했을 경우 -> 필수 파라미터 없을 때 @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e){ + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e){ log.warn("[IlleagalArgumentException] cause: {} , message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage()); ErrorMessage errorCode = ErrorMessage.ILLEGAL_ARGUMENT_ERROR; return ResponseEntity @@ -65,7 +73,6 @@ public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) .body(ErrorResponse.of(errorCode.getStatus(), errorCode.getMessage())); } - //@Valid 유효성 검사에서 예외가 발생했을 때 -> requestbody에 잘못 들어왔을 때 @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e){ log.warn("[MethodArgumentNotValidException] cause: {}, message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage()); @@ -75,7 +82,6 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho .body(ErrorResponse.of(errorCode.getStatus(), errorCode.getMessage())); } - //잘못된 포맷 요청 -> Json으로 안보내다던지 @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e){ log.warn("[HttpMessageNotReadableException] cause: {}, message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage()); @@ -84,6 +90,7 @@ public ResponseEntity handleHttpMessageNotReadableException(HttpM .status(errorCode.getStatus()) .body(ErrorResponse.of(errorCode.getStatus(), errorCode.getMessage())); } + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity handleHttpMethodException( HttpRequestMethodNotSupportedException e, @@ -96,4 +103,3 @@ public ResponseEntity handleHttpMethodException( .body(ErrorResponse.of(errorCode.getStatus(), errorCode.getMessage())); } } - From bc72e2c067cd1ec4ded97ba20fb53d647a2cc3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Sun, 15 Jun 2025 23:42:44 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat(JwtAuthenticationFilter):=20Rate=20L?= =?UTF-8?q?imit=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Bucket4j=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/build.gradle b/build.gradle index a0c8a75..784b8ef 100644 --- a/build.gradle +++ b/build.gradle @@ -55,7 +55,6 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' -// implementation 'com.nimbusds:nimbus-jose-jwt:3.10' // Gson implementation 'com.google.code.gson:gson:2.8.6' @@ -70,35 +69,20 @@ dependencies { // Spring Batch implementation 'org.springframework.boot:spring-boot-starter-batch' - implementation 'com.giffing.bucket4j.spring.boot.starter:bucket4j-spring-boot-starter:0.11.0' + // Bucket4j + implementation 'com.bucket4j:bucket4j-core:8.1.0' } -//QueryDSL 초기 설정 -//1. Q-Class를 생성할 디렉토리 경로를 설정합니다. -def queryDslSrcDir = 'src/main/generated/querydsl/' +def generatedDir = 'src/main/generated' -//2. JavaCompile Task를 수행하는 경우 생성될 소스코드의 출력 디렉토리를 queryDslSrcDir로 설정합니다. -tasks.withType(JavaCompile).configureEach { - options.getGeneratedSourceOutputDirectory().set(file(queryDslSrcDir)) -} - -//3. 소스 코드로 인식할 디렉토리 경로에 Q-Class 파일을 추가합니다. 이렇게 하면 Q-Class가 일반 Java 클래스처럼 취급되어 컴파일과 실행 시 classPath에 포함됩니다. sourceSets { - main.java.srcDirs += [queryDslSrcDir] + main.java.srcDirs += [generatedDir] } -//4. clean Task를 수행하는 경우 지정한 디렉토리를 삭제하도록 설정합니다. -> 자동 생성된 Q-Class를 제거합니다. clean { - delete file(queryDslSrcDir) -} - -//5. QueryDSL과 관련된 라이브러리들이 컴파일 시점에만 필요하도록 설정합니다. 또한, QueryDSL 설정을 컴파일 클래스 패스에 추가합니다. -configurations { - compileOnly { - extendsFrom annotationProcessor - } - querydsl.extendsFrom compileClasspath + delete file(generatedDir) } +// ================================================= tasks.named('test') { useJUnitPlatform() From b61c39eee8772d0417201b6674740fbc715edc90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Sun, 15 Jun 2025 23:43:58 +0900 Subject: [PATCH 03/18] =?UTF-8?q?refactor(GlobalExceptionHandler):=20RateL?= =?UTF-8?q?imitException=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/GlobalExceptionHandler.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/main/java/org/terning/terningserver/common/exception/GlobalExceptionHandler.java b/src/main/java/org/terning/terningserver/common/exception/GlobalExceptionHandler.java index 041161c..dd1838a 100644 --- a/src/main/java/org/terning/terningserver/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/terning/terningserver/common/exception/GlobalExceptionHandler.java @@ -3,7 +3,6 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.core.NestedExceptionUtils; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.HttpRequestMethodNotSupportedException; @@ -21,14 +20,6 @@ @Slf4j public class GlobalExceptionHandler { - @ExceptionHandler(RateLimitException.class) - public ResponseEntity handleRateLimitException(RateLimitException e) { - log.warn("[RateLimitException] Too Many Requests, message: {}", e.getMessage()); - return ResponseEntity - .status(HttpStatus.TOO_MANY_REQUESTS) - .body(ErrorResponse.of(HttpStatus.TOO_MANY_REQUESTS.value(), "요청 횟수가 너무 많습니다. 잠시 후 다시 시도해주세요.")); - } - @ExceptionHandler(CustomException.class) public ResponseEntity handleCustomException(CustomException e) { log.warn("[CustomException] cause: {}, message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage()); From 322ccb3bf87d37c156bcd70296c21c924e26f633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Sun, 15 Jun 2025 23:44:22 +0900 Subject: [PATCH 04/18] =?UTF-8?q?feat(CustomJwtAuthenticationEntryPoint):?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20401=20?= =?UTF-8?q?JSON=20=EC=97=90=EB=9F=AC=20=EC=9D=91=EB=8B=B5=EC=9D=84=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=83=9D=EC=84=B1=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CustomJwtAuthenticationEntryPoint.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/terning/terningserver/common/security/jwt/filter/CustomJwtAuthenticationEntryPoint.java b/src/main/java/org/terning/terningserver/common/security/jwt/filter/CustomJwtAuthenticationEntryPoint.java index 49e81ef..fe9aa1f 100644 --- a/src/main/java/org/terning/terningserver/common/security/jwt/filter/CustomJwtAuthenticationEntryPoint.java +++ b/src/main/java/org/terning/terningserver/common/security/jwt/filter/CustomJwtAuthenticationEntryPoint.java @@ -1,13 +1,15 @@ package org.terning.terningserver.common.security.jwt.filter; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; +import org.terning.terningserver.common.exception.dto.ErrorResponse; import org.terning.terningserver.common.security.jwt.exception.JwtErrorCode; -import org.terning.terningserver.common.security.jwt.exception.JwtException; import java.io.IOException; @@ -15,12 +17,21 @@ @RequiredArgsConstructor public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + @Override public void commence( HttpServletRequest request, HttpServletResponse response, - AuthenticationException exception + AuthenticationException authException ) throws IOException { - throw new JwtException(JwtErrorCode.INVALID_JWT_TOKEN); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + JwtErrorCode errorCode = JwtErrorCode.INVALID_JWT_TOKEN; + ErrorResponse errorResponse = ErrorResponse.of(errorCode.getStatus().value(), errorCode.getMessage()); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); } } From 79058ab35929896728fa90e2580556e332e63ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Sun, 15 Jun 2025 23:44:47 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat(JwtAuthenticationFilter):=20?= =?UTF-8?q?=EC=9E=98=EB=AA=BB=EB=90=9C=20JWT=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=97=90=20=EB=8C=80=ED=95=9C=20Rate=20Limit?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jwt/filter/JwtAuthenticationFilter.java | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java index bc7049c..90be0aa 100644 --- a/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java +++ b/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java @@ -1,14 +1,21 @@ package org.terning.terningserver.common.security.jwt.filter; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.ConsumptionProbe; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.terning.terningserver.common.security.jwt.auth.UserAuthentication; +import org.terning.terningserver.common.security.jwt.exception.JwtException; +import org.terning.terningserver.common.security.ratelimit.RateLimitingService; +import org.terning.terningserver.common.util.IpAddressUtil; import java.io.IOException; import java.util.Optional; @@ -17,15 +24,40 @@ @Component @RequiredArgsConstructor +@Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenVerifier jwtTokenVerifier; + private final RateLimitingService rateLimitingService; // RateLimitingService 주입 @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - extractToken(request) - .flatMap(jwtTokenVerifier::validateAndExtractUserId) - .ifPresent(this::authenticateUser); + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + Optional token = extractToken(request); + + if (token.isPresent()) { + try { + Long userId = jwtTokenVerifier.validateAndExtractUserId(token.get()) + .orElseThrow(() -> new JwtException(null)); + authenticateUser(userId); + + } catch (JwtException e) { + String clientIp = IpAddressUtil.getClientIp(request); + Bucket bucket = rateLimitingService.resolveBucket(clientIp); + ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1); + + if (probe.isConsumed()) { + log.error("[ERROR] 유효하지 않은 JWT 토큰 요청. IP: {}. 남은 시도 횟수: {}", clientIp, probe.getRemainingTokens()); + SecurityContextHolder.clearContext(); + } else { + long waitForRefillSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000L; + log.error("[ERROR] 과도한 JWT 토큰 요청. IP: {}. 요청을 차단합니다. 대기 시간: {}초", clientIp, waitForRefillSeconds); + response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(), "과도한 요청입니다. 잠시 후 다시 시도해주세요."); + return; + } + } + } filterChain.doFilter(request, response); } From 6b7feab8f006f2127ad40e56da02e635bedba8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Sun, 15 Jun 2025 23:45:09 +0900 Subject: [PATCH 06/18] =?UTF-8?q?refactor(JwtTokenVerifier):=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EB=B0=9C=EC=83=9D=20=EC=8B=9C=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EC=9E=90=EC=97=90=EA=B2=8C=20=EC=B1=85=EC=9E=84=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=EC=9E=84=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/security/jwt/filter/JwtTokenVerifier.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtTokenVerifier.java b/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtTokenVerifier.java index 537f590..6bd71c6 100644 --- a/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtTokenVerifier.java +++ b/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtTokenVerifier.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.terning.terningserver.common.security.jwt.application.JwtUserIdExtractor; +import org.terning.terningserver.common.security.jwt.exception.JwtException; import java.util.Optional; @@ -12,11 +13,7 @@ public class JwtTokenVerifier { private final JwtUserIdExtractor jwtUserIdExtractor; - public Optional validateAndExtractUserId(String token) { - try { - return Optional.of(jwtUserIdExtractor.extractUserId(token)); - } catch (Exception e) { - return Optional.empty(); - } + public Optional validateAndExtractUserId(String token) throws JwtException { + return Optional.of(jwtUserIdExtractor.extractUserId(token)); } } From e6ec62079282c1aed1a178ba106f6db248bfb3a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Sun, 15 Jun 2025 23:45:38 +0900 Subject: [PATCH 07/18] =?UTF-8?q?feat(RateLimitingService):=20Bucket4j?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=9C=20IP=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20Rate=20Limiting=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ratelimit/RateLimitingService.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/org/terning/terningserver/common/security/ratelimit/RateLimitingService.java diff --git a/src/main/java/org/terning/terningserver/common/security/ratelimit/RateLimitingService.java b/src/main/java/org/terning/terningserver/common/security/ratelimit/RateLimitingService.java new file mode 100644 index 0000000..49b8dad --- /dev/null +++ b/src/main/java/org/terning/terningserver/common/security/ratelimit/RateLimitingService.java @@ -0,0 +1,28 @@ +package org.terning.terningserver.common.security.ratelimit; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.Refill; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class RateLimitingService { + + private final Map cache = new ConcurrentHashMap<>(); + + public Bucket resolveBucket(String ipAddress) { + return cache.computeIfAbsent(ipAddress, this::newBucket); + } + + private Bucket newBucket(String ipAddress) { + Refill refill = Refill.intervally(10, Duration.ofMinutes(1)); + Bandwidth limit = Bandwidth.classic(10, refill); + return Bucket.builder() + .addLimit(limit) + .build(); + } +} From d94aaef38b5cd970f06ef27a3c9550c5ef7070a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Sun, 15 Jun 2025 23:45:55 +0900 Subject: [PATCH 08/18] =?UTF-8?q?feat(IpAddressUtil):=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=20IP=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/IpAddressUtil.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/main/java/org/terning/terningserver/common/util/IpAddressUtil.java diff --git a/src/main/java/org/terning/terningserver/common/util/IpAddressUtil.java b/src/main/java/org/terning/terningserver/common/util/IpAddressUtil.java new file mode 100644 index 0000000..079f353 --- /dev/null +++ b/src/main/java/org/terning/terningserver/common/util/IpAddressUtil.java @@ -0,0 +1,30 @@ +package org.terning.terningserver.common.util; + +import jakarta.servlet.http.HttpServletRequest; + +public class IpAddressUtil { + + private static final String[] IP_HEADER_CANDIDATES = { + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_X_FORWARDED_FOR", + "HTTP_X_FORWARDED", + "HTTP_X_CLUSTER_CLIENT_IP", + "HTTP_CLIENT_IP", + "HTTP_FORWARDED_FOR", + "HTTP_FORWARDED", + "HTTP_VIA", + "REMOTE_ADDR" + }; + + public static String getClientIp(HttpServletRequest request) { + for (String header : IP_HEADER_CANDIDATES) { + String ip = request.getHeader(header); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + return ip.split(",")[0]; + } + } + return request.getRemoteAddr(); + } +} From 0b01fcfbeee0a9f4052782f9757688ecb73d03ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Sun, 15 Jun 2025 23:46:50 +0900 Subject: [PATCH 09/18] =?UTF-8?q?refactor(Company):=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=EB=A5=BC=20=ED=8F=AC=ED=95=A8=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=83=9D=EC=84=B1=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../terningserver/internshipAnnouncement/domain/Company.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/Company.java b/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/Company.java index 475d118..29249ba 100644 --- a/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/Company.java +++ b/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/Company.java @@ -5,6 +5,7 @@ import jakarta.persistence.Embeddable; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -13,6 +14,7 @@ @Embeddable @Getter @NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor public class Company { @Column(nullable = false, length = 64) From dd54ab1173977ae77ed3f22e6fefec63cc01cb9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Sun, 15 Jun 2025 23:47:05 +0900 Subject: [PATCH 10/18] =?UTF-8?q?refactor(InternshipAnnouncement):=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=ED=95=98=EB=8A=94=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internshipAnnouncement/domain/InternshipAnnouncement.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/InternshipAnnouncement.java b/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/InternshipAnnouncement.java index a15c478..ebdde49 100644 --- a/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/InternshipAnnouncement.java +++ b/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/InternshipAnnouncement.java @@ -1,6 +1,7 @@ package org.terning.terningserver.internshipAnnouncement.domain; import jakarta.persistence.*; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.terning.terningserver.common.BaseTimeEntity; @@ -15,6 +16,7 @@ @Entity @Getter @NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor public class InternshipAnnouncement extends BaseTimeEntity { @Id From c4cc53a82e44504b68189c76c6fd8e9390518474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Sun, 15 Jun 2025 23:47:24 +0900 Subject: [PATCH 11/18] =?UTF-8?q?test(JwtAuthenticationFilter):=20Rate=20L?= =?UTF-8?q?imit=20=EA=B8=B0=EB=8A=A5=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filter/JwtAuthenticationFilterTest.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/test/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilterTest.java diff --git a/src/test/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilterTest.java b/src/test/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..741ad49 --- /dev/null +++ b/src/test/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilterTest.java @@ -0,0 +1,65 @@ +package org.terning.terningserver.common.security.jwt.filter; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.terning.terningserver.common.security.jwt.exception.JwtErrorCode; +import org.terning.terningserver.common.security.jwt.exception.JwtException; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class JwtAuthenticationFilterTest { + + private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilterTest.class); + + @Autowired + private MockMvc mockMvc; + + @MockBean + private JwtTokenVerifier jwtTokenVerifier; + + @Test + @DisplayName("잘못된 JWT 토큰으로 반복 요청 시, Rate Limit에 따라 429 에러를 반환한다") + void when_repeated_requests_with_invalid_token_then_return_429_error() throws Exception { + // given + String invalidToken = "this-is-an-invalid-token"; + when(jwtTokenVerifier.validateAndExtractUserId(anyString())) + .thenThrow(new JwtException(JwtErrorCode.INVALID_JWT_TOKEN)); + + // when & then + log.info("====== Rate Limit 허용 횟수 내 요청 테스트 시작 ======"); + for (int i = 1; i <= 10; i++) { + log.info("{}번째 요청", i); + // when + ResultActions actions = mockMvc.perform(get("/api/v1/any-secured-endpoint") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken)); + // then + actions.andExpect(status().isUnauthorized()); + } + log.info("====== Rate Limit 허용 횟수 내 요청 테스트 통과 ======"); + + + // when & then + log.info("\n====== Rate Limit 초과 요청 테스트 시작 ======"); + log.info("11번째 요청"); + // when + ResultActions finalAction = mockMvc.perform(get("/api/v1/any-secured-endpoint") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken)); + // then + finalAction.andExpect(status().isTooManyRequests()); + log.info("====== Rate Limit 초과 요청 테스트 통과 ======"); + } +} From e1dc0b0b237eb35454fa01804f5c9e334d1bf844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Sun, 15 Jun 2025 23:47:41 +0900 Subject: [PATCH 12/18] =?UTF-8?q?style(ScrapServiceTest):=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/terning/terningserver/service/ScrapServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/org/terning/terningserver/service/ScrapServiceTest.java b/src/test/java/org/terning/terningserver/service/ScrapServiceTest.java index f57ae64..a7aa910 100644 --- a/src/test/java/org/terning/terningserver/service/ScrapServiceTest.java +++ b/src/test/java/org/terning/terningserver/service/ScrapServiceTest.java @@ -211,4 +211,4 @@ public void setup() { assertThat(savedAnnouncement.getScrapCount()).isEqualTo(100L); } } -} \ No newline at end of file +} From d21b1dae426528dc1a34a1355a8e76ae4e3a335c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Mon, 16 Jun 2025 00:19:42 +0900 Subject: [PATCH 13/18] =?UTF-8?q?refactor(JwtAuthenticationFilter):=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/security/jwt/filter/JwtAuthenticationFilter.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java index 90be0aa..a143b85 100644 --- a/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java +++ b/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java @@ -28,7 +28,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenVerifier jwtTokenVerifier; - private final RateLimitingService rateLimitingService; // RateLimitingService 주입 + private final RateLimitingService rateLimitingService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -38,8 +38,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (token.isPresent()) { try { - Long userId = jwtTokenVerifier.validateAndExtractUserId(token.get()) - .orElseThrow(() -> new JwtException(null)); + Long userId = jwtTokenVerifier.validateAndExtractUserId(token.get()).get(); authenticateUser(userId); } catch (JwtException e) { From e05ae06cec1e9698ae5928f8159bb26abd526eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Mon, 16 Jun 2025 00:22:31 +0900 Subject: [PATCH 14/18] =?UTF-8?q?refactor(JwtAuthenticationFilter):=20?= =?UTF-8?q?=EC=9E=98=EB=AA=BB=EB=90=9C=20=ED=86=A0=ED=81=B0=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=8B=9C=20401=20=EC=9D=91=EB=8B=B5=EC=9D=84=20?= =?UTF-8?q?=EC=A6=89=EC=8B=9C=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/security/jwt/filter/JwtAuthenticationFilter.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java index a143b85..df6a715 100644 --- a/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java +++ b/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java @@ -49,6 +49,10 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (probe.isConsumed()) { log.error("[ERROR] 유효하지 않은 JWT 토큰 요청. IP: {}. 남은 시도 횟수: {}", clientIp, probe.getRemainingTokens()); SecurityContextHolder.clearContext(); + + response.sendError(HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다."); + return; + } else { long waitForRefillSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000L; log.error("[ERROR] 과도한 JWT 토큰 요청. IP: {}. 요청을 차단합니다. 대기 시간: {}초", clientIp, waitForRefillSeconds); From 019d77c8afa66d5643b96bbd92589837d7083236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=AF=E1=86=AB=E1=84=8C=E1=85=A1=E1=86=BC?= =?UTF-8?q?=E1=84=89=E1=85=AE=E1=86=AB?= Date: Mon, 16 Jun 2025 18:33:27 +0900 Subject: [PATCH 15/18] =?UTF-8?q?refactor(JwtFilter)=20:=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jwt/filter/JwtAuthenticationFilter.java | 7 +++-- .../security/jwt/filter/JwtTokenVerifier.java | 19 ------------ .../filter/JwtAuthenticationFilterTest.java | 30 ++++++------------- 3 files changed, 13 insertions(+), 43 deletions(-) delete mode 100644 src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtTokenVerifier.java diff --git a/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java index df6a715..48ef308 100644 --- a/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java +++ b/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilter.java @@ -12,6 +12,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.terning.terningserver.common.security.jwt.application.JwtUserIdExtractor; import org.terning.terningserver.common.security.jwt.auth.UserAuthentication; import org.terning.terningserver.common.security.jwt.exception.JwtException; import org.terning.terningserver.common.security.ratelimit.RateLimitingService; @@ -27,7 +28,7 @@ @Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final JwtTokenVerifier jwtTokenVerifier; + private final JwtUserIdExtractor jwtUserIdExtractor; private final RateLimitingService rateLimitingService; @Override @@ -38,7 +39,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (token.isPresent()) { try { - Long userId = jwtTokenVerifier.validateAndExtractUserId(token.get()).get(); + Long userId = jwtUserIdExtractor.extractUserId(token.get()); authenticateUser(userId); } catch (JwtException e) { @@ -47,7 +48,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1); if (probe.isConsumed()) { - log.error("[ERROR] 유효하지 않은 JWT 토큰 요청. IP: {}. 남은 시도 횟수: {}", clientIp, probe.getRemainingTokens()); + log.warn("[WARN] 유효하지 않은 JWT 토큰 요청. IP: {}. 남은 시도 횟수: {}", clientIp, probe.getRemainingTokens()); SecurityContextHolder.clearContext(); response.sendError(HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다."); diff --git a/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtTokenVerifier.java b/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtTokenVerifier.java deleted file mode 100644 index 6bd71c6..0000000 --- a/src/main/java/org/terning/terningserver/common/security/jwt/filter/JwtTokenVerifier.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.terning.terningserver.common.security.jwt.filter; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.terning.terningserver.common.security.jwt.application.JwtUserIdExtractor; -import org.terning.terningserver.common.security.jwt.exception.JwtException; - -import java.util.Optional; - -@Service -@RequiredArgsConstructor -public class JwtTokenVerifier { - - private final JwtUserIdExtractor jwtUserIdExtractor; - - public Optional validateAndExtractUserId(String token) throws JwtException { - return Optional.of(jwtUserIdExtractor.extractUserId(token)); - } -} diff --git a/src/test/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilterTest.java b/src/test/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilterTest.java index 741ad49..b2c4297 100644 --- a/src/test/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilterTest.java +++ b/src/test/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilterTest.java @@ -2,8 +2,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -11,6 +9,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; +import org.terning.terningserver.common.security.jwt.application.JwtUserIdExtractor; import org.terning.terningserver.common.security.jwt.exception.JwtErrorCode; import org.terning.terningserver.common.security.jwt.exception.JwtException; @@ -23,43 +22,32 @@ @AutoConfigureMockMvc class JwtAuthenticationFilterTest { - private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilterTest.class); - @Autowired private MockMvc mockMvc; @MockBean - private JwtTokenVerifier jwtTokenVerifier; + private JwtUserIdExtractor jwtUserIdExtractor; @Test @DisplayName("잘못된 JWT 토큰으로 반복 요청 시, Rate Limit에 따라 429 에러를 반환한다") void when_repeated_requests_with_invalid_token_then_return_429_error() throws Exception { // given String invalidToken = "this-is-an-invalid-token"; - when(jwtTokenVerifier.validateAndExtractUserId(anyString())) + when(jwtUserIdExtractor.extractUserId(anyString())) .thenThrow(new JwtException(JwtErrorCode.INVALID_JWT_TOKEN)); - // when & then - log.info("====== Rate Limit 허용 횟수 내 요청 테스트 시작 ======"); - for (int i = 1; i <= 10; i++) { - log.info("{}번째 요청", i); - // when - ResultActions actions = mockMvc.perform(get("/api/v1/any-secured-endpoint") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken)); - // then - actions.andExpect(status().isUnauthorized()); + // when + for (int i = 0; i < 10; i++) { + mockMvc.perform(get("/api/v1/any-secured-endpoint") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken)) + .andExpect(status().isUnauthorized()); // then } - log.info("====== Rate Limit 허용 횟수 내 요청 테스트 통과 ======"); - - // when & then - log.info("\n====== Rate Limit 초과 요청 테스트 시작 ======"); - log.info("11번째 요청"); // when ResultActions finalAction = mockMvc.perform(get("/api/v1/any-secured-endpoint") .header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken)); + // then finalAction.andExpect(status().isTooManyRequests()); - log.info("====== Rate Limit 초과 요청 테스트 통과 ======"); } } From 5e1985b2f3eb412a1bbea7984c2bdfa997db87d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Tue, 29 Jul 2025 21:43:36 +0900 Subject: [PATCH 16/18] =?UTF-8?q?[FEAT]=20=EC=95=A0=ED=94=8C=EB=A6=AC?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=85=98=20=EC=84=B1=EB=8A=A5=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=A9=94=ED=85=8C=EC=9A=B0=EC=8A=A4=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index 03a0352..1a0dcab 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,9 @@ dependencies { // Bucket4j implementation 'com.bucket4j:bucket4j-core:8.1.0' + + // Monitoring + implementation 'io.micrometer:micrometer-registry-prometheus' } def generatedDir = 'src/main/generated' From 25c6c238ec3a07c245df1079b39812ce9b05bc33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Tue, 29 Jul 2025 22:11:55 +0900 Subject: [PATCH 17/18] =?UTF-8?q?[MERGE]=20develop=EA=B3=BC=20align=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=94=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 31 ++++++++--- .../exception/GlobalExceptionHandler.java | 2 +- .../common/util/IpAddressUtil.java | 30 ----------- .../domain/Company.java | 1 - .../domain/InternshipAnnouncement.java | 1 - .../filter/JwtAuthenticationFilterTest.java | 53 ------------------- 6 files changed, 24 insertions(+), 94 deletions(-) delete mode 100644 src/main/java/org/terning/terningserver/common/util/IpAddressUtil.java delete mode 100644 src/test/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilterTest.java diff --git a/build.gradle b/build.gradle index 1a0dcab..2da5e0c 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,7 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' +// implementation 'com.nimbusds:nimbus-jose-jwt:3.10' // Gson implementation 'com.google.code.gson:gson:2.8.6' @@ -68,24 +69,38 @@ dependencies { // Spring Batch implementation 'org.springframework.boot:spring-boot-starter-batch' - // Bucket4j - implementation 'com.bucket4j:bucket4j-core:8.1.0' - // Monitoring implementation 'io.micrometer:micrometer-registry-prometheus' + } -def generatedDir = 'src/main/generated' +//QueryDSL 초기 설정 +//1. Q-Class를 생성할 디렉토리 경로를 설정합니다. +def queryDslSrcDir = 'src/main/generated/querydsl/' + +//2. JavaCompile Task를 수행하는 경우 생성될 소스코드의 출력 디렉토리를 queryDslSrcDir로 설정합니다. +tasks.withType(JavaCompile).configureEach { + options.getGeneratedSourceOutputDirectory().set(file(queryDslSrcDir)) +} +//3. 소스 코드로 인식할 디렉토리 경로에 Q-Class 파일을 추가합니다. 이렇게 하면 Q-Class가 일반 Java 클래스처럼 취급되어 컴파일과 실행 시 classPath에 포함됩니다. sourceSets { - main.java.srcDirs += [generatedDir] + main.java.srcDirs += [queryDslSrcDir] } +//4. clean Task를 수행하는 경우 지정한 디렉토리를 삭제하도록 설정합니다. -> 자동 생성된 Q-Class를 제거합니다. clean { - delete file(generatedDir) + delete file(queryDslSrcDir) +} + +//5. QueryDSL과 관련된 라이브러리들이 컴파일 시점에만 필요하도록 설정합니다. 또한, QueryDSL 설정을 컴파일 클래스 패스에 추가합니다. +configurations { + compileOnly { + extendsFrom annotationProcessor + } + querydsl.extendsFrom compileClasspath } -// ================================================= tasks.named('test') { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/src/main/java/org/terning/terningserver/common/exception/GlobalExceptionHandler.java b/src/main/java/org/terning/terningserver/common/exception/GlobalExceptionHandler.java index fcdc3dc..988cd6d 100644 --- a/src/main/java/org/terning/terningserver/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/terning/terningserver/common/exception/GlobalExceptionHandler.java @@ -93,4 +93,4 @@ public ResponseEntity handleHttpMethodException( .status(errorCode.getStatus()) .body(ErrorResponse.of(errorCode.getStatus(), errorCode.getMessage())); } -} +} \ No newline at end of file diff --git a/src/main/java/org/terning/terningserver/common/util/IpAddressUtil.java b/src/main/java/org/terning/terningserver/common/util/IpAddressUtil.java deleted file mode 100644 index 079f353..0000000 --- a/src/main/java/org/terning/terningserver/common/util/IpAddressUtil.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.terning.terningserver.common.util; - -import jakarta.servlet.http.HttpServletRequest; - -public class IpAddressUtil { - - private static final String[] IP_HEADER_CANDIDATES = { - "X-Forwarded-For", - "Proxy-Client-IP", - "WL-Proxy-Client-IP", - "HTTP_X_FORWARDED_FOR", - "HTTP_X_FORWARDED", - "HTTP_X_CLUSTER_CLIENT_IP", - "HTTP_CLIENT_IP", - "HTTP_FORWARDED_FOR", - "HTTP_FORWARDED", - "HTTP_VIA", - "REMOTE_ADDR" - }; - - public static String getClientIp(HttpServletRequest request) { - for (String header : IP_HEADER_CANDIDATES) { - String ip = request.getHeader(header); - if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { - return ip.split(",")[0]; - } - } - return request.getRemoteAddr(); - } -} diff --git a/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/Company.java b/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/Company.java index 29249ba..224bb86 100644 --- a/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/Company.java +++ b/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/Company.java @@ -14,7 +14,6 @@ @Embeddable @Getter @NoArgsConstructor(access = PROTECTED) -@AllArgsConstructor public class Company { @Column(nullable = false, length = 64) diff --git a/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/InternshipAnnouncement.java b/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/InternshipAnnouncement.java index ebdde49..0910285 100644 --- a/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/InternshipAnnouncement.java +++ b/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/InternshipAnnouncement.java @@ -16,7 +16,6 @@ @Entity @Getter @NoArgsConstructor(access = PROTECTED) -@AllArgsConstructor public class InternshipAnnouncement extends BaseTimeEntity { @Id diff --git a/src/test/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilterTest.java b/src/test/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilterTest.java deleted file mode 100644 index b2c4297..0000000 --- a/src/test/java/org/terning/terningserver/common/security/jwt/filter/JwtAuthenticationFilterTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.terning.terningserver.common.security.jwt.filter; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.HttpHeaders; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; -import org.terning.terningserver.common.security.jwt.application.JwtUserIdExtractor; -import org.terning.terningserver.common.security.jwt.exception.JwtErrorCode; -import org.terning.terningserver.common.security.jwt.exception.JwtException; - -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@AutoConfigureMockMvc -class JwtAuthenticationFilterTest { - - @Autowired - private MockMvc mockMvc; - - @MockBean - private JwtUserIdExtractor jwtUserIdExtractor; - - @Test - @DisplayName("잘못된 JWT 토큰으로 반복 요청 시, Rate Limit에 따라 429 에러를 반환한다") - void when_repeated_requests_with_invalid_token_then_return_429_error() throws Exception { - // given - String invalidToken = "this-is-an-invalid-token"; - when(jwtUserIdExtractor.extractUserId(anyString())) - .thenThrow(new JwtException(JwtErrorCode.INVALID_JWT_TOKEN)); - - // when - for (int i = 0; i < 10; i++) { - mockMvc.perform(get("/api/v1/any-secured-endpoint") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken)) - .andExpect(status().isUnauthorized()); // then - } - - // when - ResultActions finalAction = mockMvc.perform(get("/api/v1/any-secured-endpoint") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken)); - - // then - finalAction.andExpect(status().isTooManyRequests()); - } -} From fed112a08b48f22a493e4cbd2a90f1646a31e7b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4?= Date: Tue, 29 Jul 2025 22:13:03 +0900 Subject: [PATCH 18/18] =?UTF-8?q?[REFACTOR]=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20import=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../terningserver/internshipAnnouncement/domain/Company.java | 1 - .../internshipAnnouncement/domain/InternshipAnnouncement.java | 1 - 2 files changed, 2 deletions(-) diff --git a/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/Company.java b/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/Company.java index 224bb86..475d118 100644 --- a/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/Company.java +++ b/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/Company.java @@ -5,7 +5,6 @@ import jakarta.persistence.Embeddable; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/InternshipAnnouncement.java b/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/InternshipAnnouncement.java index 0910285..a15c478 100644 --- a/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/InternshipAnnouncement.java +++ b/src/main/java/org/terning/terningserver/internshipAnnouncement/domain/InternshipAnnouncement.java @@ -1,7 +1,6 @@ package org.terning.terningserver.internshipAnnouncement.domain; import jakarta.persistence.*; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.terning.terningserver.common.BaseTimeEntity;