Skip to content

Commit 726fe03

Browse files
committed
feat(ratelimit): Add annotation-based rate limiting with Redis ZSET tracking
- Introduce `@RateLimit` annotation for per-endpoint request limits - Create `RateLimitService` using Redis ZSET for sliding-window rate limiting - Apply `@RateLimit` on endpoints - Define `TooManyRequestsException` and related exception handler
1 parent 57df619 commit 726fe03

14 files changed

Lines changed: 139 additions & 5 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package me.loghub.api.aspect.common
2+
3+
import me.loghub.api.entity.user.User
4+
import me.loghub.api.exception.common.TooManyRequestsException
5+
import me.loghub.api.lib.ratelimit.RateLimit
6+
import me.loghub.api.service.common.RateLimitService
7+
import org.aspectj.lang.JoinPoint
8+
import org.aspectj.lang.annotation.Aspect
9+
import org.aspectj.lang.annotation.Before
10+
import org.aspectj.lang.reflect.MethodSignature
11+
import org.springframework.security.core.context.SecurityContextHolder
12+
import org.springframework.stereotype.Component
13+
14+
@Aspect
15+
@Component
16+
class RateLimitAspect(private val rateLimitService: RateLimitService) {
17+
@Before("@annotation(rateLimit)")
18+
fun checkRateLimit(joinPoint: JoinPoint, rateLimit: RateLimit) {
19+
val authentication = SecurityContextHolder.getContext().authentication
20+
?: throw IllegalStateException("No authentication found")
21+
22+
val principal = authentication.principal as? User
23+
?: throw IllegalStateException("Principal is not User")
24+
25+
val methodSignature = joinPoint.signature as MethodSignature
26+
val method = methodSignature.method
27+
28+
val allowed = rateLimitService.tryConsume(
29+
userId = principal.id!!,
30+
className = method.declaringClass.name,
31+
methodName = method.name,
32+
limit = rateLimit.limit,
33+
window = rateLimit.window,
34+
unit = rateLimit.unit
35+
)
36+
37+
if (!allowed) {
38+
throw TooManyRequestsException()
39+
}
40+
}
41+
}

src/main/kotlin/me/loghub/api/constant/message/ResponseMessage.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ object ResponseMessage {
1111
const val CONFLICT = "요청이 현재 상태와 충돌합니다."
1212
const val CONFLICT_FIELD = "필드 값이 충돌합니다."
1313
const val MISSING_COOKIE = "쿠키가 전달되지 않았습니다."
14+
const val TOO_MANY_REQUESTS = "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요."
1415
const val COOLDOWN_NOT_ELAPSED = "쿨타임이 지나지 않았습니다."
1516
const val INTERNAL_SERVER_ERROR = "알 수 없는 문제가 발생했습니다. 잠시 후 다시 시도해주세요."
1617
}

src/main/kotlin/me/loghub/api/controller/user/UserController.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import me.loghub.api.dto.response.ResponseBody
77
import me.loghub.api.dto.user.UpdateUsernameDTO
88
import me.loghub.api.dto.user.UserDetailDTO
99
import me.loghub.api.entity.user.User
10+
import me.loghub.api.lib.ratelimit.RateLimit
1011
import me.loghub.api.service.user.UserService
1112
import org.springframework.http.HttpStatus
1213
import org.springframework.http.ResponseEntity
1314
import org.springframework.security.core.annotation.AuthenticationPrincipal
1415
import org.springframework.web.bind.annotation.*
1516
import org.springframework.web.multipart.MultipartFile
17+
import java.time.temporal.ChronoUnit
1618

1719
@RestController
1820
@RequestMapping("/users")
@@ -24,6 +26,7 @@ class UserController(private val userService: UserService) {
2426
}
2527

2628
@PutMapping("/username")
29+
@RateLimit(limit = 3, window = 8, unit = ChronoUnit.HOURS)
2730
fun updateUsername(
2831
@RequestBody @Valid requestBody: UpdateUsernameDTO,
2932
@AuthenticationPrincipal user: User
@@ -36,6 +39,7 @@ class UserController(private val userService: UserService) {
3639
}
3740

3841
@PutMapping("/avatar")
42+
@RateLimit(limit = 5, window = 1, unit = ChronoUnit.HOURS)
3943
fun updateAvatar(
4044
@RequestPart("file") file: MultipartFile,
4145
@AuthenticationPrincipal user: User,

src/main/kotlin/me/loghub/api/controller/user/UserImageController.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package me.loghub.api.controller.user
33
import me.loghub.api.dto.response.DataResponseBody
44
import me.loghub.api.dto.response.ResponseBody
55
import me.loghub.api.entity.user.User
6+
import me.loghub.api.lib.ratelimit.RateLimit
67
import me.loghub.api.service.user.UserImageService
78
import org.springframework.http.HttpStatus
89
import org.springframework.http.ResponseEntity
@@ -12,11 +13,13 @@ import org.springframework.web.bind.annotation.RequestMapping
1213
import org.springframework.web.bind.annotation.RequestPart
1314
import org.springframework.web.bind.annotation.RestController
1415
import org.springframework.web.multipart.MultipartFile
16+
import java.time.temporal.ChronoUnit
1517

1618
@RestController
1719
@RequestMapping("/users/image")
1820
class UserImageController(private val userImageService: UserImageService) {
1921
@PostMapping("/upload")
22+
@RateLimit(limit = 10, window = 1, unit = ChronoUnit.HOURS)
2023
fun uploadImage(
2124
@RequestPart("file") file: MultipartFile,
2225
@AuthenticationPrincipal user: User,

src/main/kotlin/me/loghub/api/exception/FieldException.kt renamed to src/main/kotlin/me/loghub/api/exception/common/FieldException.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package me.loghub.api.exception
1+
package me.loghub.api.exception.common
22

33
open class FieldException(
44
val field: String,
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package me.loghub.api.exception.common
2+
3+
import me.loghub.api.constant.message.ResponseMessage
4+
5+
class TooManyRequestsException(
6+
override val message: String = ResponseMessage.Default.TOO_MANY_REQUESTS
7+
) : RuntimeException(message)

src/main/kotlin/me/loghub/api/exception/entity/EntityExistsFieldException.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package me.loghub.api.exception.entity
22

33
import me.loghub.api.constant.message.ResponseMessage
4-
import me.loghub.api.exception.FieldException
4+
import me.loghub.api.exception.common.FieldException
55

66
class EntityExistsFieldException(
77
field: String,

src/main/kotlin/me/loghub/api/exception/entity/EntityNotFoundFieldException.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package me.loghub.api.exception.entity
22

33
import me.loghub.api.constant.message.ResponseMessage
4-
import me.loghub.api.exception.FieldException
4+
import me.loghub.api.exception.common.FieldException
55

66
class EntityNotFoundFieldException(
77
field: String,

src/main/kotlin/me/loghub/api/exception/validation/ConflictFieldException.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package me.loghub.api.exception.validation
22

33
import me.loghub.api.constant.message.ResponseMessage
4-
import me.loghub.api.exception.FieldException
4+
import me.loghub.api.exception.common.FieldException
55

66
class ConflictFieldException(
77
field: String,

src/main/kotlin/me/loghub/api/exception/validation/IllegalFieldException.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package me.loghub.api.exception.validation
22

33
import me.loghub.api.constant.message.ResponseMessage
4-
import me.loghub.api.exception.FieldException
4+
import me.loghub.api.exception.common.FieldException
55

66
class IllegalFieldException(
77
field: String,

0 commit comments

Comments
 (0)