Skip to content

Commit

Permalink
Merge branch 'main' into refactor/#45
Browse files Browse the repository at this point in the history
  • Loading branch information
RumosZin authored Sep 5, 2024
2 parents 4210ea3 + 7a36676 commit 1773de1
Show file tree
Hide file tree
Showing 18 changed files with 277 additions and 38 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
echo "${{ secrets.PROD }}" > ./application-prod.yml
- name: Test with Gradle
run: ./gradlew test
run: ./gradlew clean test --info --stacktrace

- name: Build with Gradle
run: ./gradlew build -x test
Expand Down
Binary file modified .gradle/8.8/checksums/checksums.lock
Binary file not shown.
Binary file modified .gradle/8.8/checksums/md5-checksums.bin
Binary file not shown.
Binary file modified .gradle/8.8/checksums/sha1-checksums.bin
Binary file not shown.
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,17 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'

// Rabbit MQ
implementation 'org.springframework.boot:spring-boot-starter-amqp'

// spring-retry
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'

// bucket4j
implementation 'com.bucket4j:bucket4j_jdk17-redis-common:8.13.1'
implementation 'com.bucket4j:bucket4j_jdk17-lettuce:8.13.1'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ public String getTokenFromAuthorizationHeader(String authorizationHeader) {
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
return authorizationHeader.substring(7);
}
throw new AuthException(ResponseCode.BAD_REQUEST);
throw new AuthException(ResponseCode.USER_NO_AUTH_HEADER);
}

}
49 changes: 30 additions & 19 deletions src/main/java/gdsc/cau/puangbe/auth/external/KakaoProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import gdsc.cau.puangbe.auth.config.KakaoLoginProperties;
import gdsc.cau.puangbe.auth.dto.KakaoIDTokenPublicKeyList;
import gdsc.cau.puangbe.auth.dto.KakaoToken;
import gdsc.cau.puangbe.common.exception.AuthException;
import gdsc.cau.puangbe.common.util.ResponseCode;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.Cacheable;
Expand All @@ -12,6 +14,7 @@
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;

@Component
@RequiredArgsConstructor
Expand All @@ -21,30 +24,38 @@ public class KakaoProvider {

// 카카오 인가 코드로 카카오 토큰 발급
public KakaoToken getTokenByCode(String code) {
return WebClient.create()
.post()
.uri(kakaoLoginProperties.getTokenUri())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.body(BodyInserters.
fromFormData("grant_type", "authorization_code")
.with("client_id", kakaoLoginProperties.getClientId())
.with("redirect_uri", kakaoLoginProperties.getRedirectUri())
.with("code", code)
.with("client_secret", kakaoLoginProperties.getClientSecret()))
.retrieve()
.bodyToMono(KakaoToken.class)
.block();
try {
return WebClient.create()
.post()
.uri(kakaoLoginProperties.getTokenUri())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.body(BodyInserters.
fromFormData("grant_type", "authorization_code")
.with("client_id", kakaoLoginProperties.getClientId())
.with("redirect_uri", kakaoLoginProperties.getRedirectUri())
.with("code", code)
.with("client_secret", kakaoLoginProperties.getClientSecret()))
.retrieve()
.bodyToMono(KakaoToken.class)
.block();
} catch (WebClientResponseException e) {
throw new AuthException(ResponseCode.USER_INVALID_KAKAO_CODE);
}
}

// 카카오 인증 서버로부터 카카오 ID 토큰 공개키 목록 조회하여 캐싱
@Cacheable(key = "'all'")
public KakaoIDTokenPublicKeyList getOIDCPublicKeyList() {
return WebClient.create()
.get()
.uri(kakaoLoginProperties.getPublicKeyUri())
.retrieve()
.bodyToMono(KakaoIDTokenPublicKeyList.class)
.block();
try {
return WebClient.create()
.get()
.uri(kakaoLoginProperties.getPublicKeyUri())
.retrieve()
.bodyToMono(KakaoIDTokenPublicKeyList.class)
.block();
} catch (WebClientResponseException e) {
throw new AuthException(ResponseCode.INTERNAL_SERVER_ERROR);
}
}

// 카카오 인증 서버로부터 카카오 ID 토큰 공개키 목록 재조회하여 캐싱
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ private KakaoIDTokenPayload extractPayloadFromTokenString(String token) {
// 온점(.)을 기준으로 헤더, 페이로드, 서명을 분리
String[] parts = token.split("\\.");
if (parts.length != 3) {
throw new AuthException(ResponseCode.BAD_REQUEST); // Invalid JWT token
throw new AuthException(ResponseCode.USER_INVALID_TOKEN); // Invalid JWT token
}

// 페이로드를 추출하여 Base64 방식으로 디코딩
Expand Down Expand Up @@ -100,7 +100,7 @@ private String extractHeaderKidFromTokenString(String token) {
// 온점(.)을 기준으로 헤더, 페이로드, 서명을 분리
String[] parts = token.split("\\.");
if (parts.length != 3) {
throw new AuthException(ResponseCode.BAD_REQUEST); // Invalid JWT token
throw new AuthException(ResponseCode.INTERNAL_SERVER_ERROR);
}

// 헤더을 추출하여 Base64 방식으로 디코딩
Expand All @@ -119,7 +119,7 @@ private KakaoIDTokenJWK getOIDCPublicKey(String kid, KakaoIDTokenPublicKeyList k
return kakaoIdTokenPublicKeyList.getKeys().stream()
.filter(kakaoIDTokenJWK -> kakaoIDTokenJWK.getKid().equals(kid))
.findFirst()
.orElseThrow(() -> new AuthException(ResponseCode.BAD_REQUEST)); // 일치하는 PK 없음
.orElseThrow(() -> new AuthException(ResponseCode.UNAUTHORIZED)); // 일치하는 PK 없음
}

// JWK로 RSA Public Key 생성
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package gdsc.cau.puangbe.common.config;

import io.github.bucket4j.distributed.ExpirationAfterWriteStrategy;
import io.github.bucket4j.redis.lettuce.Bucket4jLettuce;
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.codec.ByteArrayCodec;
import io.lettuce.core.codec.RedisCodec;
import io.lettuce.core.codec.StringCodec;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;

@Configuration
@RequiredArgsConstructor
public class RateLimiterConfig {

private final RedisClient redisClient;

@Bean
public LettuceBasedProxyManager<String> proxyManager() {
StatefulRedisConnection<String, byte[]> connection = redisClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));
return Bucket4jLettuce.casBasedBuilder(connection)
.expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofHours(1))) // 1시간마다 리필
.build();
}
}
12 changes: 12 additions & 0 deletions src/main/java/gdsc/cau/puangbe/common/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package gdsc.cau.puangbe.common.config;

import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
Expand All @@ -10,6 +13,10 @@

@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
String host;
@Value("${spring.data.redis.port}")
Integer port;

// String key, Long value(requestId)를 다루기 위한 RedisTemplate입니다. (현재 유저의 요청의 대기열 역할을 수행하게 됩니다)
// opsForSet().add()를 통해 requestId를 추가할 수 있으며, 이 때 key는 별도로 만든 키로 설정합니다. (RequestQueue 등)
Expand All @@ -33,4 +40,9 @@ public RedisTemplate<String, Long> redisTemplate(RedisConnectionFactory connecti
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}

@Bean
public RedisClient redisClient() {
return RedisClient.create(RedisURI.builder().withHost(host).withPort(port).build());
}
}
9 changes: 9 additions & 0 deletions src/main/java/gdsc/cau/puangbe/common/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
package gdsc.cau.puangbe.common.config;

import gdsc.cau.puangbe.common.config.resolver.PuangUserArgumentResolver;
import gdsc.cau.puangbe.common.interceptor.RateLimiterInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

private final PuangUserArgumentResolver puangUserArgumentResolver;
private final RateLimiterInterceptor rateLimiterInterceptor;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(puangUserArgumentResolver);
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimiterInterceptor);
}
}
54 changes: 54 additions & 0 deletions src/main/java/gdsc/cau/puangbe/common/enums/RateLimitPolicy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package gdsc.cau.puangbe.common.enums;

import gdsc.cau.puangbe.common.exception.RateLimiterException;
import gdsc.cau.puangbe.common.util.ResponseCode;
import io.github.bucket4j.Bandwidth;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.time.Duration;
import java.util.Arrays;

@Getter
@RequiredArgsConstructor
public enum RateLimitPolicy {
GENERAL("general") {
@Override
public Bandwidth getLimit() {
return Bandwidth.builder()
.capacity(50)
.refillIntervally(50, Duration.ofSeconds(1))
.build();
}
// 버킷 용량: 토큰 50개
// 1초에 토큰 50개씩 리필 (버킷 용량 내에서)
// 1시간마다 버킷 전체 리필(RateLimiterConfig에 설정됨)
},

HEAVY("heavy") {
@Override
public Bandwidth getLimit() {
return Bandwidth.builder()
.capacity(1)
.refillIntervally(1, Duration.ofSeconds(1))
.build();
}
// 버킷 용량: 토큰 1개
// 1초에 토큰 1개씩 리필 (버킷 용량 내에서)
// 1시간마다 버킷 전체 리필(RateLimiterConfig에 설정됨)
},

;

public abstract Bandwidth getLimit();

private final String planName;

public static Bandwidth resolvePlan(final String targetPlan) {
return Arrays.stream(RateLimitPolicy.values())
.filter(policy -> policy.getPlanName().equals(targetPlan))
.map(RateLimitPolicy::getLimit)
.findFirst()
.orElseThrow(() -> new RateLimiterException(ResponseCode.RATE_LIMITER_POLICY_ERROR));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,10 @@ public APIResponse<Void> handleAuthException(AuthException e) {
log.info("AuthException: {}", e.getMessage());
return APIResponse.fail(e.getResponseCode(), e.getMessage());
}

@ExceptionHandler(RateLimiterException.class)
public APIResponse<Void> handleRateLimiterException(RateLimiterException e) {
log.info("RateLimiterException: {}", e.getMessage());
return APIResponse.fail(e.getResponseCode(), e.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package gdsc.cau.puangbe.common.exception;

import gdsc.cau.puangbe.common.util.ResponseCode;

public class RateLimiterException extends BaseException {
public RateLimiterException(ResponseCode responseCode) {
super(responseCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package gdsc.cau.puangbe.common.interceptor;

import gdsc.cau.puangbe.auth.external.JwtProvider;
import gdsc.cau.puangbe.common.enums.RateLimitPolicy;
import gdsc.cau.puangbe.common.exception.RateLimiterException;
import gdsc.cau.puangbe.common.util.ResponseCode;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import java.time.Duration;

@Component
@RequiredArgsConstructor
public class RateLimiterInterceptor implements HandlerInterceptor {
private final LettuceBasedProxyManager<String> proxyManager;
private final RedisTemplate<String, Long> redisTemplate;
private final JwtProvider jwtProvider;

private final String LIMITER_PREFIX = "rate-limiter-count:";
private final String BLOCKED_PREFIX = "rate-limiter-blocked:";

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// key 생성 (kakaoId + API 엔트포인트)
String authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader == null) {
return true;
}

String accessToken = jwtProvider.getTokenFromAuthorizationHeader(authorizationHeader);
String kakaoId = jwtProvider.getKakaoIdFromExpiredToken(accessToken);
String servletPath = request.getServletPath();
String key = kakaoId + servletPath;

if (isBlocked(key)) {
throw new RateLimiterException(ResponseCode.RATE_LIMITER_TOO_MANY_REQUESTS);
}

// key에 해당하는 bucket 로드. 없으면 생성
Bucket bucket = proxyManager.getProxy(LIMITER_PREFIX + key, () -> getRateLimitPolicy(servletPath));

if (!bucket.tryConsume(1)) {
blockClient(key);
throw new RateLimiterException(ResponseCode.RATE_LIMITER_TOO_MANY_REQUESTS);
}
return true;
}

private BucketConfiguration bucketConfiguration(String bucketPlan) {
return BucketConfiguration.builder()
.addLimit(RateLimitPolicy.resolvePlan(bucketPlan))
.build();
}

private BucketConfiguration getRateLimitPolicy(String contextPath) {
switch (contextPath) {
case "/api/photo-request":
return bucketConfiguration("heavy");

default:
return bucketConfiguration("general");
}
}

private void blockClient(String key) {
redisTemplate.opsForValue().set(BLOCKED_PREFIX + key, 0L, Duration.ofMinutes(5));
}

private boolean isBlocked(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(BLOCKED_PREFIX + key));
}
}
Loading

0 comments on commit 1773de1

Please sign in to comment.