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
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
@@ -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
@@ -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> {
Original file line number Diff line number Diff line change
@@ -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)!!
Original file line number Diff line number Diff line change
@@ -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
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
@@ -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
}
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))
}
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()
}
}
11 changes: 11 additions & 0 deletions src/main/kotlin/com/ddd/sonnypolabobe/global/util/DateConverter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.ddd.sonnypolabobe.global.util

import java.time.LocalDateTime

object DateConverter {

fun convertToKst(date: LocalDateTime): LocalDateTime {
return date.plusHours(9)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.ddd.sonnypolabobe.global.util

import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
import reactor.netty.http.client.HttpClient
import java.time.Duration


@Component
class DiscordApiClient(
@Value("\${logging.discord.webhook-uri}")
private val discordWebhookUri: String
) {
fun sendDiscordComm(): WebClient = WebClient.builder().baseUrl(discordWebhookUri)
.clientConnector(
ReactorClientHttpConnector(
HttpClient.create().responseTimeout(Duration.ofMillis(2500))
)
)
.build()

fun sendErrorLog(req: HttpLog) {
val embedData: MutableMap<String, Any> = HashMap()

embedData["title"] = "서버 에러 발생"

val field1: MutableMap<String, String> = HashMap()
field1["name"] = "요청 정보"
field1["value"] = req.requestMethod + " " + req.requestURI + " " + req.elapsedTime + "ms"

val field2: MutableMap<String, String> = HashMap()
field2["name"] = "응답 코드"
field2["value"] = req.responseStatus.toString()

val field3: MutableMap<String, String> = HashMap()
field3["name"] = "요청 헤더"
field3["value"] = req.headers.map { it.key + " : " + it.value }.joinToString("\n")

val field4: MutableMap<String, String> = HashMap()
field4["name"] = "요청 본문"
field4["value"] = req.requestBody

val field5: MutableMap<String, String> = HashMap()
field5["name"] = "요청 파람"
field5["value"] = req.parameters.map { it.key + " : " + it.value }.joinToString("\n")

val field6: MutableMap<String, String> = HashMap()
field6["name"] = "응답 본문"
field6["value"] = req.responseBody

embedData["fields"] = listOf<Map<String, String>>(field1, field2, field3, field4, field5, field6)

val payload: MutableMap<String, Any> = HashMap()
payload["embeds"] = arrayOf<Any>(embedData)

sendDiscordComm()
.post()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(payload)
.retrieve()
.bodyToMono(Void::class.java)
.block()
}

fun sendErrorTrace(errorCode: String, message: String?, trace: String) {
val embedData: MutableMap<String, Any> = HashMap()

embedData["title"] = "서버 에러 발생"

val field1: MutableMap<String, String> = HashMap()
field1["name"] = "트레이스"
field1["value"] = trace

val field2: MutableMap<String, String> = HashMap()
field2["name"] = "에러 코드"
field2["value"] = errorCode

val field3: MutableMap<String, String> = HashMap()
field3["name"] = "메시지"
field3["value"] = message ?: "메시지 없음"

embedData["fields"] = listOf<Map<String, String>>(field1)

val payload: MutableMap<String, Any> = HashMap()
payload["embeds"] = arrayOf<Any>(embedData)

sendDiscordComm()
.post()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(payload)
.retrieve()
.bodyToMono(Void::class.java)
.block()
}

}
12 changes: 12 additions & 0 deletions src/main/kotlin/com/ddd/sonnypolabobe/global/util/HttpLog.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.ddd.sonnypolabobe.global.util

data class HttpLog(
val requestMethod : String,
val requestURI : String,
val responseStatus : Int,
val elapsedTime : Double,
val headers : Map<String, String>,
val parameters : Map<String, String>,
val requestBody : String,
val responseBody : String
)
6 changes: 5 additions & 1 deletion src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
@@ -31,4 +31,8 @@ cloud:
static: ap-northeast-2

running:
name: dev
name: dev

logging:
discord:
webhook-uri: ENC(yfeX3WHXQdxkVtasNl5WLv6M/YlN+dVFUurjxGIddstjjipt+KryWKvLu1wDmdGjpuEhUHyaABg4gFWRMk9gNlxSQEE/G1twbuvkOvT0pyFWycVVJ6ryU/v9pDBOS1PSKJY7L3NP66gOGnam6nOvf0Y+F45zZvXj8/sdtR6N798U6fGjFDxOLQ==)
6 changes: 5 additions & 1 deletion src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
@@ -31,4 +31,8 @@ cloud:
static: ap-northeast-2

running:
name: local
name: local

logging:
discord:
webhook-uri: ENC(yfeX3WHXQdxkVtasNl5WLv6M/YlN+dVFUurjxGIddstjjipt+KryWKvLu1wDmdGjpuEhUHyaABg4gFWRMk9gNlxSQEE/G1twbuvkOvT0pyFWycVVJ6ryU/v9pDBOS1PSKJY7L3NP66gOGnam6nOvf0Y+F45zZvXj8/sdtR6N798U6fGjFDxOLQ==)
5 changes: 4 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -12,4 +12,7 @@ logging:

jasypt:
encryptor:
bean: jasyptStringEncryptor
bean: jasyptStringEncryptor

limit:
count: 100
46 changes: 46 additions & 0 deletions src/main/resources/logback-spring.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<configuration scan="true" scanPeriod="5 seconds">

<timestamp key="timestamp" datePattern="yyyy-MM-dd-HH-mm-ssSSS"/>

<appender name="CONSOLE"
class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(%-4relative) --- [${appName}, %blue(%X{traceId}), %green(%X{spanId}) %X{sessionId}] %cyan(%logger{20}) : %msg%n
</pattern>
</layout>
</appender>

<springProfile name="prod">
<property name="appName" value="prod-api"/>

<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>

<springProfile name="dev">
<property name="appName" value="dev-api"/>

<logger name="org.jooq" level="DEBUG" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>

<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>


<springProfile name="local">
<property name="appName" value="local-api"/>

<logger name="org.jooq" level="DEBUG" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>

<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
</configuration>