Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 하루 요청 100개 제한 #4

Merged
merged 5 commits into from
Jul 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 17 additions & 3 deletions .github/workflows/cd-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,21 @@ jobs:
- name: Build, tag, and push image to Amazon ECR
run: |
docker build --build-arg PASSWORD=$PASSWORD -t polabo:${{steps.current-time.outputs.formattedTime}} .
docker tag polabo:${{steps.current-time.outputs.formattedTime}} 058264417437.dkr.ecr.ap-northeast-2.amazonaws.com/polabo:${{steps.current-time.outputs.formattedTime}}
docker push 058264417437.dkr.ecr.ap-northeast-2.amazonaws.com/polabo:${{steps.current-time.outputs.formattedTime}}
docker tag polabo:${{steps.current-time.outputs.formattedTime}} ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}}
docker push ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}}
env:
PASSWORD: ${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }}
PASSWORD: ${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }}

- name: Deploy
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_KEY }}
script: |
aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${{ steps.login-ecr.outputs.registry }}/${{ secrets.ECR_REPOSITORY }}
docker stop polabo-dev
docker rm polabo-dev
docker pull ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}}
docker run -d -v /etc/localtime:/etc/localtime:ro -v /usr/share/zoneinfo/Asia/Seoul:/etc/timezone:ro -e ENVIRONMENT_VALUE=-Dspring.profiles.active=dev --name polabo-dev -p 8080:8080 --restart=always --network host ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}}

3 changes: 2 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ dependencies {
implementation("software.amazon.awssdk:s3:2.20.68")
implementation("com.amazonaws:aws-java-sdk-s3:1.12.561")
implementation("com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5")

implementation("org.springframework.boot:spring-boot-starter-security")
implementation("io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64")
}

tasks.withType<KotlinCompile> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package com.ddd.sonnypolabobe
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling

@EnableScheduling
@SpringBootApplication
class SonnyPolaboBeApplication
inline fun <reified T> T.logger() = LoggerFactory.getLogger(T::class.java)!!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.ddd.sonnypolabobe.domain.board.repository

import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardCreateRequest
import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardGetResponse
import com.ddd.sonnypolabobe.global.util.DateConverter
import com.ddd.sonnypolabobe.global.util.UuidConverter
import com.ddd.sonnypolabobe.global.util.UuidGenerator
import com.ddd.sonnypolabobe.jooq.polabo.tables.Board
Expand All @@ -22,7 +23,7 @@ class BoardJooqRepositoryImpl(
val insertValue = jBoard.newRecord().apply {
this.id = id
this.title = request.title
this.createdAt = LocalDateTime.now()
this.createdAt = DateConverter.convertToKst(LocalDateTime.now())
this.yn = 1
this.activeyn = 1
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.ddd.sonnypolabobe.domain.polaroid.repository
import com.ddd.sonnypolabobe.domain.polaroid.controller.dto.PolaroidCreateRequest
import com.ddd.sonnypolabobe.global.exception.ApplicationException
import com.ddd.sonnypolabobe.global.exception.CustomErrorCode
import com.ddd.sonnypolabobe.global.util.DateConverter
import com.ddd.sonnypolabobe.jooq.polabo.tables.Polaroid
import com.ddd.sonnypolabobe.jooq.polabo.tables.records.PolaroidRecord
import org.jooq.DSLContext
Expand All @@ -17,7 +18,7 @@ class PolaroidJooqRepositoryImpl(private val dslContext: DSLContext) : PolaroidJ
this.boardId = boardId
this.imageKey = request.imageKey
this.oneLineMessage = request.oneLineMessage
this.createdAt = LocalDateTime.now()
this.createdAt = DateConverter.convertToKst(LocalDateTime.now())
this.yn = 1
this.activeyn = 1
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.ddd.sonnypolabobe.global.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.UrlBasedCorsConfigurationSource

@Configuration
@EnableMethodSecurity
class SecurityConfig() {

@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
return http
.cors {
it.configurationSource(corsConfigurationSource())
}
.csrf{
it.disable()
}
.httpBasic {
it.disable()
}
.formLogin { it.disable() }
.authorizeHttpRequests {
it.anyRequest().permitAll()
}
.build()
}

fun corsConfigurationSource(): UrlBasedCorsConfigurationSource {
val configuration = CorsConfiguration()
configuration.allowedOrigins = listOf("http://localhost:3000") // Allow all origins
configuration.allowedMethods =
listOf("GET", "POST", "PUT", "DELETE", "OPTIONS") // Allow common methods
configuration.allowedHeaders = listOf("*") // Allow all headers
configuration.allowCredentials = true // Allow credentials
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration(
"/**",
configuration
) // Apply configuration to all endpoints
return source
}
}
16 changes: 16 additions & 0 deletions src/main/kotlin/com/ddd/sonnypolabobe/global/config/WebConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.ddd.sonnypolabobe.global.config

import com.ddd.sonnypolabobe.global.security.RateLimitingInterceptor
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class WebConfig(private val rateLimitingInterceptor: RateLimitingInterceptor) : WebMvcConfigurer {

override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(rateLimitingInterceptor)
.addPathPatterns("/api/v1/boards")
}

}
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
package com.ddd.sonnypolabobe.global.exception

import com.ddd.sonnypolabobe.global.response.ApplicationResponse
import com.ddd.sonnypolabobe.global.util.DiscordApiClient
import com.ddd.sonnypolabobe.logger
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

@RestControllerAdvice
class GlobalExceptionHandler {
class GlobalExceptionHandler(
private val discordApiClient: DiscordApiClient
) {
@ExceptionHandler(ApplicationException::class)
fun applicationException(ex: ApplicationException): ResponseEntity<ApplicationResponse<Error>> {
logger().info("error : ${ex.error}")
this.discordApiClient.sendErrorTrace(
ex.error.code, ex.message,
ex.stackTrace.contentToString()
)
return ResponseEntity.status(ex.error.status).body(ApplicationResponse.error(ex.error))
}

@ExceptionHandler(MethodArgumentNotValidException::class)
fun validationException(ex: MethodArgumentNotValidException): ResponseEntity<ApplicationResponse<Error>> {
logger().info("error : ${ex.bindingResult.allErrors[0].defaultMessage}")
return ResponseEntity.status(CustomErrorCode.INVALID_VALUE_EXCEPTION.status)
.body(ApplicationResponse.error(CustomErrorCode.INVALID_VALUE_EXCEPTION, ex.bindingResult.allErrors[0].defaultMessage!!))
.body(
ApplicationResponse.error(
CustomErrorCode.INVALID_VALUE_EXCEPTION,
ex.bindingResult.allErrors[0].defaultMessage!!
)
)
}

@ExceptionHandler(RuntimeException::class)
fun runtimeException(ex: RuntimeException): ResponseEntity<ApplicationResponse<Error>> {
logger().info("error : ${ex.message}")
this.discordApiClient.sendErrorTrace(
"500", ex.message,
ex.stackTrace.contentToString()
)
return ResponseEntity.status(CustomErrorCode.INTERNAL_SERVER_EXCEPTION.status)
.body(ApplicationResponse.error(CustomErrorCode.INTERNAL_SERVER_EXCEPTION))
}
Expand Down
124 changes: 124 additions & 0 deletions src/main/kotlin/com/ddd/sonnypolabobe/global/security/LoggingFilter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package com.ddd.sonnypolabobe.global.security

import com.ddd.sonnypolabobe.global.util.DiscordApiClient
import com.ddd.sonnypolabobe.global.util.HttpLog
import com.ddd.sonnypolabobe.logger
import jakarta.servlet.FilterChain
import jakarta.servlet.ServletRequest
import jakarta.servlet.ServletResponse
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.filter.GenericFilterBean
import org.springframework.web.util.ContentCachingRequestWrapper
import org.springframework.web.util.ContentCachingResponseWrapper
import org.springframework.web.util.WebUtils
import java.io.UnsupportedEncodingException
import java.util.*

@Component
class LoggingFilter(
private val discordApiClient: DiscordApiClient
) : GenericFilterBean() {
private val excludedUrls = setOf("/actuator", "/swagger-ui")

override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
val requestWrapper: ContentCachingRequestWrapper =
ContentCachingRequestWrapper(request as HttpServletRequest)
val responseWrapper: ContentCachingResponseWrapper =
ContentCachingResponseWrapper(response as HttpServletResponse)
if (excludeLogging(request.requestURI)) {
chain.doFilter(request, response)
} else {
val startedAt = System.currentTimeMillis()
chain.doFilter(requestWrapper, responseWrapper)
val endedAt = System.currentTimeMillis()

logger().info(
"\n" +
"[REQUEST] ${request.method} - ${request.requestURI} ${responseWrapper.status} - ${(endedAt - startedAt) / 10000.0} \n" +
"Headers : ${getHeaders(request)} \n" +
"Parameters : ${getRequestParams(request)} \n" +
"Request body : ${getRequestBody(requestWrapper)} \n" +
"Response body : ${getResponseBody(responseWrapper)}"
)

if(responseWrapper.status >= 400) {
this.discordApiClient.sendErrorLog(
HttpLog(
request.method,
request.requestURI,
responseWrapper.status,
(endedAt - startedAt) / 10000.0,
getHeaders(request),
getRequestParams(request),
getRequestBody(requestWrapper),
getResponseBody(responseWrapper)
)
)
}
}
}

private fun excludeLogging(requestURI: String): Boolean {
return excludedUrls.contains(requestURI)
}

private fun getResponseBody(response: ContentCachingResponseWrapper): String {
var payload: String? = null
response.characterEncoding = "utf-8"
val wrapper =
WebUtils.getNativeResponse(response, ContentCachingResponseWrapper::class.java)
if (wrapper != null) {
val buf = wrapper.contentAsByteArray
if (buf.isNotEmpty()) {
payload = String(buf, 0, buf.size, charset(wrapper.characterEncoding))
wrapper.copyBodyToResponse()
}
}
return payload ?: " - "
}

private fun getRequestBody(request: ContentCachingRequestWrapper): String {
request.characterEncoding = "utf-8"
val wrapper = WebUtils.getNativeRequest<ContentCachingRequestWrapper>(
request,
ContentCachingRequestWrapper::class.java
)
if (wrapper != null) {
val buf = wrapper.contentAsByteArray
if (buf.isNotEmpty()) {
return try {
String(buf, 0, buf.size, charset(wrapper.characterEncoding))
} catch (e: UnsupportedEncodingException) {
" - "
}
}
}
return " - "
}

private fun getRequestParams(request: HttpServletRequest): Map<String, String> {
val parameterMap: MutableMap<String, String> = HashMap()
request.characterEncoding = "utf-8"
val parameterArray: Enumeration<*> = request.parameterNames

while (parameterArray.hasMoreElements()) {
val parameterName = parameterArray.nextElement() as String
parameterMap[parameterName] = request.getParameter(parameterName)
}

return parameterMap
}

private fun getHeaders(request: HttpServletRequest): Map<String, String> {
val headerMap: MutableMap<String, String> = HashMap()

val headerArray: Enumeration<*> = request.headerNames
while (headerArray.hasMoreElements()) {
val headerName = headerArray.nextElement() as String
headerMap[headerName] = request.getHeader(headerName)
}
return headerMap
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.ddd.sonnypolabobe.global.security

import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor

@Component
class RateLimitingInterceptor(private val rateLimitingService: RateLimitingService) :
HandlerInterceptor {

override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
// 특정 URL 패턴을 필터링합니다.
if (request.requestURI == "/api/v1/boards" && request.method == "POST") {
if (!rateLimitingService.incrementRequestCount()) {
response.status = HttpStatus.TOO_MANY_REQUESTS.value()
response.writer.write("Daily request limit exceeded")
return false
}
}
return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.ddd.sonnypolabobe.global.security

import com.ddd.sonnypolabobe.logger
import org.springframework.beans.factory.annotation.Value
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap

@Service
class RateLimitingService(
@Value("\${limit.count}")
private val limit: Int
) {

private val requestCounts = ConcurrentHashMap<String, Int>()
private val LIMIT = limit
private val REQUEST_KEY = "api_request_count"

fun incrementRequestCount(): Boolean {
val currentCount = requestCounts.getOrDefault(REQUEST_KEY, 0)

if (currentCount >= LIMIT) {
return false
}

requestCounts[REQUEST_KEY] = currentCount + 1
logger().info("Request count: ${requestCounts[REQUEST_KEY]}")
return true
}

@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
fun resetRequestCount() {
requestCounts.clear()
}
}
Loading
Loading