feat(server): Rate-Limiting auf alle API-Endpoints
Ktor RateLimit-Plugin mit abgestuften Limits pro Endpoint-Gruppe: - Auth (/api/auth/*): 10 req/min per IP (Brute-Force-Schutz) - Messages (/api/messages/*): 30 req/min per IP (Spam-Schutz) - Inventory (/api/inventory/*): 60 req/min per IP (DoS-Schutz) - Admin (/api/admin/*): 20 req/min per IP Neue Dateien: - RateLimiting.kt: Plugin-Konfiguration mit 4 benannten Limitern - RateLimitingTest.kt: 5 Tests (Limit-Ueberschreitung, Within-Limit, Health-Endpoint ohne Limit, Retry-After-Header) Geaenderte Dateien: - Routing.kt: rateLimit()-Wrapper um Route-Gruppen - Application.kt: configureRateLimiting() in Plugin-Pipeline - libs.versions.toml + build.gradle.kts: ktor-server-rate-limit Dep Closes #75
This commit is contained in:
parent
0f25c180ed
commit
7c17f8ea2f
6 changed files with 199 additions and 5 deletions
|
|
@ -63,6 +63,7 @@ ktor-server-status-pages = { group = "io.ktor", name = "ktor-server-status-pages
|
|||
ktor-server-auth = { group = "io.ktor", name = "ktor-server-auth", version.ref = "ktor" }
|
||||
ktor-server-call-logging = { group = "io.ktor", name = "ktor-server-call-logging", version.ref = "ktor" }
|
||||
ktor-server-auth-jwt = { group = "io.ktor", name = "ktor-server-auth-jwt", version.ref = "ktor" }
|
||||
ktor-server-rate-limit = { group = "io.ktor", name = "ktor-server-rate-limit", version.ref = "ktor" }
|
||||
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
|
||||
ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
|
||||
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ dependencies {
|
|||
implementation(libs.ktor.server.auth)
|
||||
implementation(libs.ktor.server.auth.jwt)
|
||||
implementation(libs.ktor.server.websockets)
|
||||
implementation(libs.ktor.server.rate.limit)
|
||||
implementation(libs.ktor.server.call.logging)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.jbcrypt)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package de.krisenvorrat.server
|
|||
import de.krisenvorrat.server.db.DatabaseFactory
|
||||
import de.krisenvorrat.server.plugins.configureAuthentication
|
||||
import de.krisenvorrat.server.plugins.configureCallLogging
|
||||
import de.krisenvorrat.server.plugins.configureRateLimiting
|
||||
import de.krisenvorrat.server.plugins.configureRouting
|
||||
import de.krisenvorrat.server.plugins.configureSerialization
|
||||
import de.krisenvorrat.server.plugins.configureStatusPages
|
||||
|
|
@ -28,6 +29,7 @@ internal fun Application.configurePlugins() {
|
|||
configureSerialization()
|
||||
configureStatusPages()
|
||||
configureCallLogging()
|
||||
configureRateLimiting()
|
||||
configureAuthentication()
|
||||
configureRouting()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
package de.krisenvorrat.server.plugins
|
||||
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.ratelimit.*
|
||||
import io.ktor.server.request.*
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
internal val RATE_LIMIT_AUTH = RateLimitName("auth")
|
||||
internal val RATE_LIMIT_MESSAGES = RateLimitName("messages")
|
||||
internal val RATE_LIMIT_INVENTORY = RateLimitName("inventory")
|
||||
internal val RATE_LIMIT_ADMIN = RateLimitName("admin")
|
||||
|
||||
internal fun Application.configureRateLimiting() {
|
||||
install(RateLimit) {
|
||||
// Auth endpoints: 10 requests per minute per IP (brute-force protection)
|
||||
register(RATE_LIMIT_AUTH) {
|
||||
rateLimiter(limit = 10, refillPeriod = 1.minutes)
|
||||
requestKey { call ->
|
||||
call.request.local.remoteAddress
|
||||
}
|
||||
}
|
||||
|
||||
// Message endpoints: 30 requests per minute per user
|
||||
register(RATE_LIMIT_MESSAGES) {
|
||||
rateLimiter(limit = 30, refillPeriod = 1.minutes)
|
||||
requestKey { call ->
|
||||
call.request.local.remoteAddress
|
||||
}
|
||||
}
|
||||
|
||||
// Inventory endpoints: 60 requests per minute per user
|
||||
register(RATE_LIMIT_INVENTORY) {
|
||||
rateLimiter(limit = 60, refillPeriod = 1.minutes)
|
||||
requestKey { call ->
|
||||
call.request.local.remoteAddress
|
||||
}
|
||||
}
|
||||
|
||||
// Admin endpoints: 20 requests per minute per user
|
||||
register(RATE_LIMIT_ADMIN) {
|
||||
rateLimiter(limit = 20, refillPeriod = 1.minutes)
|
||||
requestKey { call ->
|
||||
call.request.local.remoteAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import io.ktor.server.application.*
|
|||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.http.content.*
|
||||
import io.ktor.server.plugins.*
|
||||
import io.ktor.server.plugins.ratelimit.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
|
@ -53,14 +54,22 @@ internal fun Application.configureRouting(
|
|||
call.respondText("OK", ContentType.Text.Plain, HttpStatusCode.OK)
|
||||
}
|
||||
|
||||
// Public auth endpoints
|
||||
// Public auth endpoints (10 req/min per IP)
|
||||
rateLimit(RATE_LIMIT_AUTH) {
|
||||
authRoutes(userRepository, jwtService)
|
||||
}
|
||||
|
||||
// Protected endpoints
|
||||
authenticate("auth-jwt") {
|
||||
rateLimit(RATE_LIMIT_INVENTORY) {
|
||||
inventoryRoutes(inventoryRepository, wsManager)
|
||||
}
|
||||
rateLimit(RATE_LIMIT_ADMIN) {
|
||||
adminRoutes(userRepository, inventoryRepository)
|
||||
}
|
||||
rateLimit(RATE_LIMIT_MESSAGES) {
|
||||
messageRoutes(messageRepository, userRepository, wsManager)
|
||||
}
|
||||
userRoutes(userRepository)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
package de.krisenvorrat.server
|
||||
|
||||
import de.krisenvorrat.server.db.DatabaseFactory
|
||||
import de.krisenvorrat.server.model.ErrorResponse
|
||||
import de.krisenvorrat.server.model.LoginRequest
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.config.*
|
||||
import io.ktor.server.testing.*
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RateLimitingTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
|
||||
environment {
|
||||
config = MapApplicationConfig(*testMapConfig().toTypedArray())
|
||||
}
|
||||
application {
|
||||
DatabaseFactory.init(
|
||||
jdbcUrl = "jdbc:h2:mem:ratelimit_test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
|
||||
driver = "org.h2.Driver",
|
||||
adminPassword = "test-admin-pw"
|
||||
)
|
||||
configurePlugins()
|
||||
}
|
||||
block()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_authLogin_exceedsRateLimit_returns429() = testApp {
|
||||
// Given: auth rate limit is 10 req/min
|
||||
val loginBody = json.encodeToString(LoginRequest("admin", "wrong-password"))
|
||||
|
||||
// When: send 11 requests (exceeding the 10 req/min limit)
|
||||
var lastStatus: HttpStatusCode = HttpStatusCode.OK
|
||||
for (i in 1..11) {
|
||||
val response = client.post("/api/auth/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(loginBody)
|
||||
}
|
||||
lastStatus = response.status
|
||||
}
|
||||
|
||||
// Then: the 11th request should be rate-limited
|
||||
assertEquals(HttpStatusCode.TooManyRequests, lastStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_authLogin_withinRateLimit_notBlocked() = testApp {
|
||||
// Given: auth rate limit is 10 req/min
|
||||
val loginBody = json.encodeToString(LoginRequest("admin", "wrong-password"))
|
||||
|
||||
// When: send exactly 10 requests (within limit)
|
||||
val statuses = (1..10).map {
|
||||
client.post("/api/auth/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(loginBody)
|
||||
}.status
|
||||
}
|
||||
|
||||
// Then: none should be 429
|
||||
assertTrue(
|
||||
"Expected no 429 responses within limit, got: $statuses",
|
||||
statuses.none { it == HttpStatusCode.TooManyRequests }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_inventoryGet_exceedsRateLimit_returns429() = testApp {
|
||||
// Given: inventory rate limit is 60 req/min
|
||||
val token = createTestAccessToken()
|
||||
|
||||
// When: send 61 requests
|
||||
var lastStatus: HttpStatusCode = HttpStatusCode.OK
|
||||
for (i in 1..61) {
|
||||
val response = client.get("/api/inventory") {
|
||||
bearerAuth(token)
|
||||
}
|
||||
lastStatus = response.status
|
||||
}
|
||||
|
||||
// Then: the 61st request should be rate-limited
|
||||
assertEquals(HttpStatusCode.TooManyRequests, lastStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_healthEndpoint_noRateLimit_alwaysOk() = testApp {
|
||||
// Given: health endpoint has no rate limit
|
||||
|
||||
// When: send many requests
|
||||
val statuses = (1..100).map {
|
||||
client.get("/api/health").status
|
||||
}
|
||||
|
||||
// Then: all should be 200
|
||||
assertTrue(
|
||||
"Health endpoint should never be rate-limited",
|
||||
statuses.all { it == HttpStatusCode.OK }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_rateLimitResponse_containsRetryAfterHeader() = testApp {
|
||||
// Given: exceed auth rate limit
|
||||
val loginBody = json.encodeToString(LoginRequest("admin", "wrong-password"))
|
||||
repeat(10) {
|
||||
client.post("/api/auth/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(loginBody)
|
||||
}
|
||||
}
|
||||
|
||||
// When: send one more request
|
||||
val response = client.post("/api/auth/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(loginBody)
|
||||
}
|
||||
|
||||
// Then: should have Retry-After header
|
||||
assertEquals(HttpStatusCode.TooManyRequests, response.status)
|
||||
val retryAfter = response.headers["Retry-After"]
|
||||
assertTrue(
|
||||
"Expected Retry-After header in 429 response",
|
||||
retryAfter != null
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue