Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0be5c3d
feat(bucket4j) : μ˜μ‘΄μ„± μΆ”κ°€
jsoonworld Jun 14, 2025
bc72e2c
feat(JwtAuthenticationFilter): Rate Limit κΈ°λŠ₯ μΆ”κ°€λ₯Ό μœ„ν•œ Bucket4j μ˜μ‘΄μ„± μ„€μ •
jsoonworld Jun 15, 2025
b61c39e
refactor(GlobalExceptionHandler): RateLimitException ν•Έλ“€λŸ¬ 제거
jsoonworld Jun 15, 2025
322ccb3
feat(CustomJwtAuthenticationEntryPoint): 인증 μ‹€νŒ¨ μ‹œ 401 JSON μ—λŸ¬ 응닡을 직접 생…
jsoonworld Jun 15, 2025
79058ab
feat(JwtAuthenticationFilter): 잘λͺ»λœ JWT 토큰 μš”μ²­μ— λŒ€ν•œ Rate Limit 적용
jsoonworld Jun 15, 2025
6b7feab
refactor(JwtTokenVerifier): μ˜ˆμ™Έ λ°œμƒ μ‹œ ν˜ΈμΆœμžμ—κ²Œ μ±…μž„μ„ μœ„μž„ν•˜λ„λ‘ λ³€κ²½
jsoonworld Jun 15, 2025
e6ec620
feat(RateLimitingService): Bucket4jλ₯Ό μ΄μš©ν•œ IP 기반 Rate Limiting μ„œλΉ„μŠ€ κ΅¬ν˜„
jsoonworld Jun 15, 2025
d94aaef
feat(IpAddressUtil): ν΄λΌμ΄μ–ΈνŠΈ IP μ£Όμ†Œ μΆ”μΆœ μœ ν‹Έλ¦¬ν‹° κ΅¬ν˜„
jsoonworld Jun 15, 2025
0b01fcf
refactor(Company): 전체 ν•„λ“œλ₯Ό ν¬ν•¨ν•˜λŠ” μƒμ„±μž μΆ”κ°€
jsoonworld Jun 15, 2025
dd54ab1
refactor(InternshipAnnouncement): 전체 ν•„λ“œλ₯Ό μ΄ˆκΈ°ν™”ν•˜λŠ” μƒμ„±μž μΆ”κ°€
jsoonworld Jun 15, 2025
c4cc53a
test(JwtAuthenticationFilter): Rate Limit κΈ°λŠ₯에 λŒ€ν•œ 톡합 ν…ŒμŠ€νŠΈ μΆ”κ°€
jsoonworld Jun 15, 2025
e1dc0b0
style(ScrapServiceTest): μ½”λ“œ μŠ€νƒ€μΌ μˆ˜μ •
jsoonworld Jun 15, 2025
d21b1da
refactor(JwtAuthenticationFilter): λΆˆν•„μš”ν•œ μ˜ˆμ™Έ 처리 둜직 제거 및 μ½”λ“œ κ°„μ†Œν™”
jsoonworld Jun 15, 2025
e05ae06
refactor(JwtAuthenticationFilter): 잘λͺ»λœ 토큰 μš”μ²­ μ‹œ 401 응닡을 μ¦‰μ‹œ λ°˜ν™˜ν•˜λ„λ‘ μˆ˜μ •
jsoonworld Jun 15, 2025
019d77c
refactor(JwtFilter) : μ½”λ“œ 리뷰 반영
jsoonworld Jun 16, 2025
03aadcc
merge(jwt) : 잘λͺ»λœ JWT 토큰 μš”μ²­μ— λŒ€ν•œ Rate Limit 적용
jsoonworld Jun 16, 2025
d383d41
merge(jwt) : 잘λͺ»λœ JWT 토큰 μš”μ²­μ— λŒ€ν•œ Rate Limit 적용
jsoonworld Jun 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 7 additions & 22 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -70,35 +69,21 @@ dependencies {
// Spring Batch
implementation 'org.springframework.boot:spring-boot-starter-batch'

// 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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,25 +47,23 @@ public ResponseEntity<ErrorResponse> handleAuthException(JwtException e) {
}

@ExceptionHandler(Exception.class)
public ResponseEntity handleException(Exception e){
public ResponseEntity<ErrorResponse> handleException(Exception e){
log.warn("[Exception] cause: {} , message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage());
ErrorMessage errorCode = ErrorMessage.INTERNAL_SERVER_ERROR;
return ResponseEntity
.status(errorCode.getStatus())
.body(ErrorResponse.of(errorCode.getStatus(), errorCode.getMessage()));
}

//λ©”μ†Œλ“œκ°€ 잘λͺ»λ˜μ—ˆκ±°λ‚˜ λΆ€μ ν•©ν•œ 인수λ₯Ό μ „λ‹¬ν–ˆμ„ 경우 -> ν•„μˆ˜ νŒŒλΌλ―Έν„° 없을 λ•Œ
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e){
public ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException e){
log.warn("[IlleagalArgumentException] cause: {} , message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage());
ErrorMessage errorCode = ErrorMessage.ILLEGAL_ARGUMENT_ERROR;
return ResponseEntity
.status(errorCode.getStatus())
.body(ErrorResponse.of(errorCode.getStatus(), errorCode.getMessage()));
}

//@Valid μœ νš¨μ„± κ²€μ‚¬μ—μ„œ μ˜ˆμ™Έκ°€ λ°œμƒν–ˆμ„ λ•Œ -> requestbody에 잘λͺ» 듀어왔을 λ•Œ
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
log.warn("[MethodArgumentNotValidException] cause: {}, message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage());
Expand All @@ -75,7 +73,6 @@ public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(Metho
.body(ErrorResponse.of(errorCode.getStatus(), errorCode.getMessage()));
}

//잘λͺ»λœ 포맷 μš”μ²­ -> Json으둜 μ•ˆλ³΄λ‚΄λ‹€λ˜μ§€
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleHttpMessageNotReadableException(HttpMessageNotReadableException e){
log.warn("[HttpMessageNotReadableException] cause: {}, message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage());
Expand All @@ -84,6 +81,7 @@ public ResponseEntity<ErrorResponse> handleHttpMessageNotReadableException(HttpM
.status(errorCode.getStatus())
.body(ErrorResponse.of(errorCode.getStatus(), errorCode.getMessage()));
}

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleHttpMethodException(
HttpRequestMethodNotSupportedException e,
Expand All @@ -96,4 +94,3 @@ public ResponseEntity<ErrorResponse> handleHttpMethodException(
.body(ErrorResponse.of(errorCode.getStatus(), errorCode.getMessage()));
}
}

Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
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;

@Component
@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));
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
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.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;
import org.terning.terningserver.common.util.IpAddressUtil;

import java.io.IOException;
import java.util.Optional;
Expand All @@ -17,15 +25,43 @@

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtTokenVerifier jwtTokenVerifier;
private final JwtUserIdExtractor jwtUserIdExtractor;
private final 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<String> token = extractToken(request);

if (token.isPresent()) {
try {
Long userId = jwtUserIdExtractor.extractUserId(token.get());
authenticateUser(userId);

} catch (JwtException e) {
String clientIp = IpAddressUtil.getClientIp(request);
Bucket bucket = rateLimitingService.resolveBucket(clientIp);
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

if (probe.isConsumed()) {
log.warn("[WARN] μœ νš¨ν•˜μ§€ μ•Šμ€ 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);
response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(), "κ³Όλ„ν•œ μš”μ²­μž…λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.");
return;
}
}
}

filterChain.doFilter(request, response);
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<String, Bucket> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -13,6 +14,7 @@
@Embeddable
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
public class Company {

@Column(nullable = false, length = 64)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,6 +16,7 @@
@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
public class InternshipAnnouncement extends BaseTimeEntity {

@Id
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,4 @@ public void setup() {
assertThat(savedAnnouncement.getScrapCount()).isEqualTo(100L);
}
}
}
}
Loading