diff --git a/build.gradle b/build.gradle index 6f206741..de56e0ab 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,7 @@ dependencies { // security implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' // aws implementation platform('software.amazon.awssdk:bom:2.20.0') diff --git a/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java b/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java new file mode 100644 index 00000000..2204a5b6 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java @@ -0,0 +1,20 @@ +package life.mosu.mosuserver.global.config; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Data +@Component +@ConfigurationProperties(prefix = "ratelimit") +@Validated +public class IpRateLimitingProperties { + + @Min(1) + private int maxRequestsPerMinute; + @Min(1) + private long timeWindowMs; +} diff --git a/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java b/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java index 34ef1fb7..56f81849 100644 --- a/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java +++ b/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java @@ -11,6 +11,7 @@ public enum ErrorCode { // Principal 관련 에러 PRINCIPAL_NOT_FOUND(HttpStatus.UNAUTHORIZED, "인증된 사용자를 찾을 수 없습니다."), LOGIN_BLOCKED(HttpStatus.UNAUTHORIZED, "로그인 시도가 차단되었습니다. 잠시 후 다시 시도해주세요."), + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), // OAuth 관련 에러 UNSUPPORTED_OAUTH2_PROVIDER(HttpStatus.BAD_REQUEST, "지원하지 않는 OAuth2 제공자입니다."), diff --git a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java new file mode 100644 index 00000000..1244e583 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java @@ -0,0 +1,72 @@ +package life.mosu.mosuserver.global.filter; + +import static life.mosu.mosuserver.global.util.IpUtil.getClientIp; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import life.mosu.mosuserver.global.config.IpRateLimitingProperties; +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import life.mosu.mosuserver.global.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Component +public class IpRateLimitingFilter extends OncePerRequestFilter { + + private final IpRateLimitingProperties ipRateLimitingProperties; + private final Cache ipRequestCounts; + + public IpRateLimitingFilter(IpRateLimitingProperties ipRateLimitingProperties) { + this.ipRateLimitingProperties = ipRateLimitingProperties; + this.ipRequestCounts = Caffeine.newBuilder() + .expireAfterWrite(ipRateLimitingProperties.getTimeWindowMs(), TimeUnit.MILLISECONDS) + .recordStats() + .build(); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String ip = getClientIp(request); + RequestCounter counter = ipRequestCounts.get(ip, k -> new RequestCounter()); + + synchronized (counter) { + counter.increment(); + + if (isBlocked(counter)){ + log.warn("차단된 IP: {}, 요청 횟수: {}", ip, counter.count); + handleBlockedIp(); + } + } + + log.info("IP: {}, 요청 횟수 증가 후: {}", ip, counter.count); + log.debug("Cache stats: {}", ipRequestCounts.stats()); + + filterChain.doFilter(request, response); + } + + private boolean isBlocked(RequestCounter counter) { + return counter.count >= ipRateLimitingProperties.getMaxRequestsPerMinute(); + } + + private void handleBlockedIp(){ + throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); + } + + private static class RequestCounter { + int count = 0; + + void increment() { + count++; + } + } +} diff --git a/src/main/java/life/mosu/mosuserver/global/util/IpUtil.java b/src/main/java/life/mosu/mosuserver/global/util/IpUtil.java new file mode 100644 index 00000000..f49176ce --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/util/IpUtil.java @@ -0,0 +1,33 @@ +package life.mosu.mosuserver.global.util; + +import jakarta.servlet.http.HttpServletRequest; +import java.net.InetAddress; +import java.net.UnknownHostException; + +public class IpUtil { + public static String getClientIp(HttpServletRequest request) throws UnknownHostException { + String ip = request.getHeader("X-Forwarded-For"); + + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + + if (isInvalidIp(ip)) ip = request.getHeader("Proxy-Client-IP"); + if (isInvalidIp(ip)) ip = request.getHeader("WL-Proxy-Client-IP"); + if (isInvalidIp(ip)) ip = request.getHeader("HTTP_CLIENT_IP"); + if (isInvalidIp(ip)) ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + if (isInvalidIp(ip)) ip = request.getHeader("X-Real-IP"); + if (isInvalidIp(ip)) ip = request.getRemoteAddr(); + + if ("127.0.0.1".equals(ip) || "0:0:0:0:0:0:0:1".equals(ip)) { + InetAddress inetAddress = InetAddress.getLocalHost(); + ip = inetAddress.getHostName() + "/" + inetAddress.getHostAddress(); + } + + return ip; + } + + private static boolean isInvalidIp(String ip) { + return ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip); + } +} \ No newline at end of file diff --git a/src/main/resources/security-config.yml b/src/main/resources/security-config.yml index ede4e603..369f4bfc 100644 --- a/src/main/resources/security-config.yml +++ b/src/main/resources/security-config.yml @@ -48,4 +48,8 @@ pbkdf2: login: max-attempt: 5 - lock-time-milli-seconds: 60000 # 5 minutes \ No newline at end of file + lock-time-milli-seconds: 60000 # 5 minutes + +ratelimit: + max-requests-per-minute: 50 + time-window-ms: 60000 # 1 minute \ No newline at end of file