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-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-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-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-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-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" }
|
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)
|
||||||
implementation(libs.ktor.server.auth.jwt)
|
implementation(libs.ktor.server.auth.jwt)
|
||||||
implementation(libs.ktor.server.websockets)
|
implementation(libs.ktor.server.websockets)
|
||||||
|
implementation(libs.ktor.server.rate.limit)
|
||||||
implementation(libs.ktor.server.call.logging)
|
implementation(libs.ktor.server.call.logging)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
implementation(libs.jbcrypt)
|
implementation(libs.jbcrypt)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package de.krisenvorrat.server
|
||||||
import de.krisenvorrat.server.db.DatabaseFactory
|
import de.krisenvorrat.server.db.DatabaseFactory
|
||||||
import de.krisenvorrat.server.plugins.configureAuthentication
|
import de.krisenvorrat.server.plugins.configureAuthentication
|
||||||
import de.krisenvorrat.server.plugins.configureCallLogging
|
import de.krisenvorrat.server.plugins.configureCallLogging
|
||||||
|
import de.krisenvorrat.server.plugins.configureRateLimiting
|
||||||
import de.krisenvorrat.server.plugins.configureRouting
|
import de.krisenvorrat.server.plugins.configureRouting
|
||||||
import de.krisenvorrat.server.plugins.configureSerialization
|
import de.krisenvorrat.server.plugins.configureSerialization
|
||||||
import de.krisenvorrat.server.plugins.configureStatusPages
|
import de.krisenvorrat.server.plugins.configureStatusPages
|
||||||
|
|
@ -28,6 +29,7 @@ internal fun Application.configurePlugins() {
|
||||||
configureSerialization()
|
configureSerialization()
|
||||||
configureStatusPages()
|
configureStatusPages()
|
||||||
configureCallLogging()
|
configureCallLogging()
|
||||||
|
configureRateLimiting()
|
||||||
configureAuthentication()
|
configureAuthentication()
|
||||||
configureRouting()
|
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.auth.*
|
||||||
import io.ktor.server.http.content.*
|
import io.ktor.server.http.content.*
|
||||||
import io.ktor.server.plugins.*
|
import io.ktor.server.plugins.*
|
||||||
|
import io.ktor.server.plugins.ratelimit.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
|
@ -53,14 +54,22 @@ internal fun Application.configureRouting(
|
||||||
call.respondText("OK", ContentType.Text.Plain, HttpStatusCode.OK)
|
call.respondText("OK", ContentType.Text.Plain, HttpStatusCode.OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public auth endpoints
|
// Public auth endpoints (10 req/min per IP)
|
||||||
authRoutes(userRepository, jwtService)
|
rateLimit(RATE_LIMIT_AUTH) {
|
||||||
|
authRoutes(userRepository, jwtService)
|
||||||
|
}
|
||||||
|
|
||||||
// Protected endpoints
|
// Protected endpoints
|
||||||
authenticate("auth-jwt") {
|
authenticate("auth-jwt") {
|
||||||
inventoryRoutes(inventoryRepository, wsManager)
|
rateLimit(RATE_LIMIT_INVENTORY) {
|
||||||
adminRoutes(userRepository, inventoryRepository)
|
inventoryRoutes(inventoryRepository, wsManager)
|
||||||
messageRoutes(messageRepository, userRepository, wsManager)
|
}
|
||||||
|
rateLimit(RATE_LIMIT_ADMIN) {
|
||||||
|
adminRoutes(userRepository, inventoryRepository)
|
||||||
|
}
|
||||||
|
rateLimit(RATE_LIMIT_MESSAGES) {
|
||||||
|
messageRoutes(messageRepository, userRepository, wsManager)
|
||||||
|
}
|
||||||
userRoutes(userRepository)
|
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