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:
Jens Reinemann 2026-05-17 03:31:57 +02:00
parent 0f25c180ed
commit 7c17f8ea2f
6 changed files with 199 additions and 5 deletions

View file

@ -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" }

View file

@ -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)

View file

@ -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()
}

View file

@ -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
}
}
}
}

View file

@ -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)
}

View file

@ -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
)
}
}