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:
parent
5974144621
commit
cb9bd2bdf4
8 changed files with 236 additions and 4 deletions
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue