feat(server): add API-Key authentication for REST endpoints

server/plugins/Authentication.kt:
- Custom Ktor AuthenticationProvider supporting both X-API-Key header
  and Authorization: Bearer <key> for API-Key validation
- ApiKeyPrincipal data class implementing Principal interface
- 401 Unauthorized with ErrorResponse body for missing/invalid keys

server/plugins/Routing.kt:
- Inventory routes wrapped in authenticate(api-key) block
- Health endpoint remains public (no auth required)

server/src/main/resources/application.conf:
- API key configurable via krisenvorrat.apiKey property
- Environment variable override via KRISENVORRAT_API_KEY

server/tests:
- 7 new AuthenticationTest cases (valid bearer, valid X-API-Key,
  missing key, invalid bearer, invalid X-API-Key, PUT without key,
  health without key)
- All existing ApplicationTest cases updated with bearer auth header

Closes #43
This commit is contained in:
Jens Reinemann 2026-05-14 20:50:16 +02:00
parent 5974144621
commit cb9bd2bdf4
8 changed files with 236 additions and 4 deletions

View file

@ -55,6 +55,7 @@ ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-conte
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-server-config-yaml = { group = "io.ktor", name = "ktor-server-config-yaml", version.ref = "ktor" }
ktor-server-status-pages = { group = "io.ktor", name = "ktor-server-status-pages", 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-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }

View file

@ -32,6 +32,7 @@ dependencies {
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.server.status.pages)
implementation(libs.ktor.server.auth)
implementation(libs.ktor.server.call.logging)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.logback.classic)

View file

@ -1,6 +1,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.configureRouting
import de.krisenvorrat.server.plugins.configureSerialization
@ -21,5 +22,6 @@ internal fun Application.configurePlugins() {
configureSerialization()
configureStatusPages()
configureCallLogging()
configureAuthentication()
configureRouting()
}

View file

@ -0,0 +1,82 @@
package de.krisenvorrat.server.plugins
import de.krisenvorrat.server.model.ErrorResponse
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.response.*
internal data class ApiKeyPrincipal(val clientId: String) : Principal
internal fun Application.configureAuthentication() {
val apiKey = environment.config.property("krisenvorrat.apiKey").getString()
install(Authentication) {
apiKey("api-key") {
validate { key ->
if (key == apiKey) ApiKeyPrincipal("api-client") else null
}
}
}
}
private fun AuthenticationConfig.apiKey(
name: String,
configure: ApiKeyAuthConfig.() -> Unit
) {
val config = ApiKeyAuthConfig(name).apply(configure)
register(ApiKeyAuthProvider(config))
}
private class ApiKeyAuthConfig(name: String) : AuthenticationProvider.Config(name) {
var validateFunction: (String) -> Principal? = { null }
fun validate(block: (String) -> Principal?) {
validateFunction = block
}
}
private class ApiKeyAuthProvider(config: ApiKeyAuthConfig) : AuthenticationProvider(config) {
private val validateFunction = config.validateFunction
override suspend fun onAuthenticate(context: AuthenticationContext) {
val token = extractToken(context.call)
if (token == null) {
context.challenge("ApiKey", AuthenticationFailedCause.NoCredentials) { challenge, call ->
call.respond(
HttpStatusCode.Unauthorized,
ErrorResponse(
status = HttpStatusCode.Unauthorized.value,
message = "Missing API key"
)
)
challenge.complete()
}
return
}
val principal = validateFunction(token)
if (principal != null) {
context.principal(principal)
} else {
context.challenge("ApiKey", AuthenticationFailedCause.InvalidCredentials) { challenge, call ->
call.respond(
HttpStatusCode.Unauthorized,
ErrorResponse(
status = HttpStatusCode.Unauthorized.value,
message = "Invalid API key"
)
)
challenge.complete()
}
}
}
private fun extractToken(call: ApplicationCall): String? {
call.request.headers["X-API-Key"]?.let { return it }
val authHeader = call.request.headers[HttpHeaders.Authorization] ?: return null
if (authHeader.startsWith("Bearer ", ignoreCase = true)) {
return authHeader.substring(7).trim()
}
return null
}
}

View file

@ -4,6 +4,7 @@ import de.krisenvorrat.server.repository.InventoryRepository
import de.krisenvorrat.server.routes.inventoryRoutes
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
@ -12,6 +13,8 @@ internal fun Application.configureRouting(repository: InventoryRepository = Inve
get("/api/health") {
call.respondText("OK", ContentType.Text.Plain, HttpStatusCode.OK)
}
inventoryRoutes(repository)
authenticate("api-key") {
inventoryRoutes(repository)
}
}
}

View file

@ -7,3 +7,8 @@ ktor {
modules = [ de.krisenvorrat.server.ApplicationKt.module ]
}
}
krisenvorrat {
apiKey = "change-me-to-a-secure-key-at-least-32-chars"
apiKey = ${?KRISENVORRAT_API_KEY}
}

View file

@ -10,6 +10,7 @@ import de.krisenvorrat.shared.model.SettingDto
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.json.Json
import org.junit.Assert.assertEquals
@ -23,6 +24,9 @@ class ApplicationTest {
}
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
environment {
config = MapApplicationConfig("krisenvorrat.apiKey" to TEST_API_KEY)
}
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
@ -55,7 +59,9 @@ class ApplicationTest {
@Test
fun test_getInventory_emptyDatabase_returnsEmptyInventory() = testApp {
// When
val response = client.get("/api/inventory")
val response = client.get("/api/inventory") {
bearerAuth(TEST_API_KEY)
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
@ -74,6 +80,7 @@ class ApplicationTest {
// When
val response = client.put("/api/inventory") {
bearerAuth(TEST_API_KEY)
contentType(ContentType.Application.Json)
setBody(body)
}
@ -95,12 +102,15 @@ class ApplicationTest {
// When PUT
client.put("/api/inventory") {
bearerAuth(TEST_API_KEY)
contentType(ContentType.Application.Json)
setBody(body)
}
// When GET
val response = client.get("/api/inventory")
val response = client.get("/api/inventory") {
bearerAuth(TEST_API_KEY)
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
@ -116,6 +126,7 @@ class ApplicationTest {
fun test_putInventory_invalidJson_returns400() = testApp {
// When
val response = client.put("/api/inventory") {
bearerAuth(TEST_API_KEY)
contentType(ContentType.Application.Json)
setBody("{invalid json}")
}
@ -132,6 +143,7 @@ class ApplicationTest {
// Given first PUT
val firstInventory = createTestInventory()
client.put("/api/inventory") {
bearerAuth(TEST_API_KEY)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), firstInventory))
}
@ -144,6 +156,7 @@ class ApplicationTest {
settings = listOf(SettingDto(key = "lang", value = "de"))
)
val response = client.put("/api/inventory") {
bearerAuth(TEST_API_KEY)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), updatedInventory))
}
@ -159,7 +172,9 @@ class ApplicationTest {
@Test
fun test_getInventory_returnsJsonContentType() = testApp {
// When
val response = client.get("/api/inventory")
val response = client.get("/api/inventory") {
bearerAuth(TEST_API_KEY)
}
// Then
assertEquals(ContentType.Application.Json.withCharset(Charsets.UTF_8), response.contentType())
@ -194,4 +209,8 @@ class ApplicationTest {
SettingDto(key = "theme", value = "dark")
)
)
companion object {
const val TEST_API_KEY = "test-api-key-for-integration-tests"
}
}

View file

@ -0,0 +1,119 @@
package de.krisenvorrat.server
import de.krisenvorrat.server.db.DatabaseFactory
import de.krisenvorrat.server.model.ErrorResponse
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.json.Json
import org.junit.Assert.assertEquals
import org.junit.Test
class AuthenticationTest {
private val json = Json { ignoreUnknownKeys = true }
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
environment {
config = MapApplicationConfig("krisenvorrat.apiKey" to TEST_API_KEY)
}
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:auth_test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver"
)
configurePlugins()
}
block()
}
@Test
fun test_inventoryGet_validBearerToken_returns200() = testApp {
// When
val response = client.get("/api/inventory") {
bearerAuth(TEST_API_KEY)
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
}
@Test
fun test_inventoryGet_validXApiKeyHeader_returns200() = testApp {
// When
val response = client.get("/api/inventory") {
header("X-API-Key", TEST_API_KEY)
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
}
@Test
fun test_inventoryGet_missingApiKey_returns401() = testApp {
// When
val response = client.get("/api/inventory")
// Then
assertEquals(HttpStatusCode.Unauthorized, response.status)
val error = json.decodeFromString<ErrorResponse>(response.bodyAsText())
assertEquals(401, error.status)
assertEquals("Missing API key", error.message)
}
@Test
fun test_inventoryGet_invalidApiKey_returns401() = testApp {
// When
val response = client.get("/api/inventory") {
bearerAuth("wrong-key")
}
// Then
assertEquals(HttpStatusCode.Unauthorized, response.status)
val error = json.decodeFromString<ErrorResponse>(response.bodyAsText())
assertEquals(401, error.status)
assertEquals("Invalid API key", error.message)
}
@Test
fun test_inventoryGet_invalidXApiKey_returns401() = testApp {
// When
val response = client.get("/api/inventory") {
header("X-API-Key", "wrong-key")
}
// Then
assertEquals(HttpStatusCode.Unauthorized, response.status)
val error = json.decodeFromString<ErrorResponse>(response.bodyAsText())
assertEquals(401, error.status)
assertEquals("Invalid API key", error.message)
}
@Test
fun test_inventoryPut_missingApiKey_returns401() = testApp {
// When
val response = client.put("/api/inventory") {
contentType(ContentType.Application.Json)
setBody("{}")
}
// Then
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun test_healthEndpoint_noApiKey_returns200() = testApp {
// When
val response = client.get("/api/health")
// Then
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("OK", response.bodyAsText())
}
companion object {
private const val TEST_API_KEY = "test-api-key-for-auth-tests"
}
}