From c4e7b740551d1dcb8405a144f5f5cff7cc5dddc9 Mon Sep 17 00:00:00 2001 From: Lukas Forst Date: Sun, 10 Apr 2022 13:17:30 +0200 Subject: [PATCH 1/2] migrate implementation to ktor2 --- README.md | 2 + build.gradle.kts | 13 ++- .../apikey/ApiKeyAuthenticationProvider.kt | 85 +++++++------- .../forst/ktor/apikey/MinimalExampleApp.kt | 20 ++-- .../dev/forst/ktor/apikey/TestApiKeyAuth.kt | 107 ++++++++++-------- .../dev/forst/ktor/apikey/TestApplication.kt | 30 ++--- .../ktor/apikey/TestMinimalExampleApp.kt | 27 ++--- 7 files changed, 145 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index 676209a..66f6d8a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Include following in your `build.gradle.kts`: implementation("dev.forst", "ktor-api-key", "1.0.0") ``` +Versions >= `1.1.0` have implementation for Ktor >= `2.0.0`, use `1.0.0` if you need support for older versions of Ktor. + ## Usage This is minimal implementation of the Ktor app that uses API Key authentication: diff --git a/build.gradle.kts b/build.gradle.kts index fe48a7e..a939a25 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,7 @@ import java.net.URL plugins { - kotlin("jvm") version "1.6.10" + kotlin("jvm") version "1.6.20" `maven-publish` signing @@ -25,15 +25,18 @@ repositories { dependencies { compileOnly(kotlin("stdlib-jdk8")) // Ktor server dependencies - val ktorVersion = "1.6.7" + val ktorVersion = "2.0.0" compileOnly("io.ktor", "ktor-server-core", ktorVersion) - compileOnly("io.ktor", "ktor-auth", ktorVersion) + compileOnly("io.ktor", "ktor-server-auth", ktorVersion) // testing testImplementation("io.ktor", "ktor-server-core", ktorVersion) testImplementation("io.ktor", "ktor-server-test-host", ktorVersion) - testImplementation("io.ktor", "ktor-auth", ktorVersion) - testImplementation("io.ktor", "ktor-jackson", ktorVersion) + testImplementation("io.ktor", "ktor-server-auth", ktorVersion) + testImplementation("io.ktor", "ktor-server-content-negotiation", ktorVersion) + testImplementation("io.ktor", "ktor-serialization-jackson", ktorVersion) + testImplementation("io.ktor", "ktor-client-content-negotiation", ktorVersion) + testImplementation(kotlin("test")) testImplementation(kotlin("stdlib-jdk8")) diff --git a/src/main/kotlin/dev/forst/ktor/apikey/ApiKeyAuthenticationProvider.kt b/src/main/kotlin/dev/forst/ktor/apikey/ApiKeyAuthenticationProvider.kt index 5d69ff3..a749302 100644 --- a/src/main/kotlin/dev/forst/ktor/apikey/ApiKeyAuthenticationProvider.kt +++ b/src/main/kotlin/dev/forst/ktor/apikey/ApiKeyAuthenticationProvider.kt @@ -1,42 +1,60 @@ package dev.forst.ktor.apikey -import io.ktor.application.ApplicationCall -import io.ktor.application.call -import io.ktor.auth.Authentication -import io.ktor.auth.AuthenticationContext -import io.ktor.auth.AuthenticationFailedCause -import io.ktor.auth.AuthenticationPipeline -import io.ktor.auth.AuthenticationProcedureChallenge -import io.ktor.auth.AuthenticationProvider -import io.ktor.auth.Principal import io.ktor.http.HttpStatusCode -import io.ktor.request.header -import io.ktor.response.respond -import io.ktor.util.pipeline.PipelineInterceptor +import io.ktor.server.application.ApplicationCall +import io.ktor.server.auth.AuthenticationConfig +import io.ktor.server.auth.AuthenticationContext +import io.ktor.server.auth.AuthenticationFailedCause +import io.ktor.server.auth.AuthenticationProvider +import io.ktor.server.auth.Principal +import io.ktor.server.request.header +import io.ktor.server.response.respond + /** * Represents an API Key authentication provider. - * @property name is the name of the provider, or `null` for a default provider. */ class ApiKeyAuthenticationProvider internal constructor( configuration: Configuration ) : AuthenticationProvider(configuration) { - internal val headerName: String = configuration.headerName + private val headerName: String = configuration.headerName + + private val authenticationFunction = configuration.authenticationFunction + + private val challengeFunction = configuration.challengeFunction + + private val authScheme = configuration.authScheme - internal val authenticationFunction = configuration.authenticationFunction + override suspend fun onAuthenticate(context: AuthenticationContext) { + val apiKey = context.call.request.header(headerName) + val principal = apiKey?.let { authenticationFunction(context.call, it) } + + val cause = when { + apiKey == null -> AuthenticationFailedCause.NoCredentials + principal == null -> AuthenticationFailedCause.InvalidCredentials + else -> null + } - internal val challengeFunction = configuration.challengeFunction + if (cause != null) { + context.challenge(authScheme, cause) { challenge, call -> + challengeFunction(call) - internal val authScheme = configuration.authScheme + challenge.complete() + } + } + if (principal != null) { + context.principal(principal) + } + } /** * Api key auth configuration. */ - class Configuration internal constructor(name: String?) : AuthenticationProvider.Configuration(name) { + class Configuration internal constructor(name: String?) : Config(name) { internal lateinit var authenticationFunction: ApiKeyAuthenticationFunction - internal var challengeFunction: ApiKeyAuthChallengeFunction = { + internal var challengeFunction: ApiKeyAuthChallengeFunction = { call -> call.respond(HttpStatusCode.Unauthorized) } @@ -70,36 +88,11 @@ class ApiKeyAuthenticationProvider internal constructor( /** * Installs API Key authentication mechanism. */ -fun Authentication.Configuration.apiKey( +fun AuthenticationConfig.apiKey( name: String? = null, configure: ApiKeyAuthenticationProvider.Configuration.() -> Unit ) { val provider = ApiKeyAuthenticationProvider(ApiKeyAuthenticationProvider.Configuration(name).apply(configure)) - val headerName = provider.headerName - val authenticate = provider.authenticationFunction - val authScheme = provider.authScheme - val challenge = provider.challengeFunction - - provider.pipeline.intercept(AuthenticationPipeline.RequestAuthentication) { context -> - val apiKey = call.request.header(headerName) - val principal = apiKey?.let { authenticate(call, it) } - - val cause = when { - apiKey == null -> AuthenticationFailedCause.NoCredentials - principal == null -> AuthenticationFailedCause.InvalidCredentials - else -> null - } - if (cause != null) { - context.challenge(authScheme, cause) { - challenge.invoke(this, it) - it.complete() - } - } - if (principal != null) { - context.principal(principal) - } - } - register(provider) } @@ -111,4 +104,4 @@ typealias ApiKeyAuthenticationFunction = suspend ApplicationCall.(String) -> Pri /** * Alias for function signature that is called when authentication fails. */ -typealias ApiKeyAuthChallengeFunction = PipelineInterceptor +typealias ApiKeyAuthChallengeFunction = suspend (ApplicationCall) -> Unit diff --git a/src/test/kotlin/dev/forst/ktor/apikey/MinimalExampleApp.kt b/src/test/kotlin/dev/forst/ktor/apikey/MinimalExampleApp.kt index c1959d7..0b14d6e 100644 --- a/src/test/kotlin/dev/forst/ktor/apikey/MinimalExampleApp.kt +++ b/src/test/kotlin/dev/forst/ktor/apikey/MinimalExampleApp.kt @@ -1,15 +1,15 @@ package dev.forst.ktor.apikey -import io.ktor.application.Application -import io.ktor.application.call -import io.ktor.application.install -import io.ktor.auth.Authentication -import io.ktor.auth.Principal -import io.ktor.auth.authenticate -import io.ktor.auth.principal -import io.ktor.response.respondText -import io.ktor.routing.get -import io.ktor.routing.routing +import io.ktor.server.application.Application +import io.ktor.server.application.call +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.Principal +import io.ktor.server.auth.authenticate +import io.ktor.server.auth.principal +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.routing /** * Minimal Ktor application with API Key authentication. diff --git a/src/test/kotlin/dev/forst/ktor/apikey/TestApiKeyAuth.kt b/src/test/kotlin/dev/forst/ktor/apikey/TestApiKeyAuth.kt index d812ebf..72552c2 100644 --- a/src/test/kotlin/dev/forst/ktor/apikey/TestApiKeyAuth.kt +++ b/src/test/kotlin/dev/forst/ktor/apikey/TestApiKeyAuth.kt @@ -1,12 +1,14 @@ package dev.forst.ktor.apikey -import io.ktor.application.call -import io.ktor.auth.Principal -import io.ktor.http.HttpMethod +import io.ktor.client.call.body +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.header import io.ktor.http.HttpStatusCode -import io.ktor.response.respond -import io.ktor.server.testing.handleRequest -import io.ktor.server.testing.withTestApplication +import io.ktor.serialization.jackson.jackson +import io.ktor.server.auth.Principal +import io.ktor.server.response.respond +import io.ktor.server.testing.testApplication import org.junit.jupiter.api.Test import java.util.UUID import kotlin.test.assertEquals @@ -25,24 +27,23 @@ class TestApiKeyAuth { validate { header -> header.takeIf { it == apiKey }?.let { ApiKeyPrincipal(it) } } } - withTestApplication(module) { - handleRequest(HttpMethod.Get, Routes.open).apply { - assertEquals(HttpStatusCode.OK, response.status()) - } + testApplication { + application(module) + val client = createClient { install(ContentNegotiation) { jackson() } } + + var response = client.get(Routes.open) + assertEquals(HttpStatusCode.OK, response.status) - handleRequest(HttpMethod.Get, Routes.open) { - addHeader(defaultHeader, apiKey) - }.apply { - assertEquals(HttpStatusCode.OK, response.status()) + response = client.get(Routes.open) { + header(defaultHeader, apiKey) } + assertEquals(HttpStatusCode.OK, response.status) - handleRequest(HttpMethod.Get, Routes.open) { - addHeader(defaultHeader, "$apiKey-wrong") - }.apply { - assertEquals(HttpStatusCode.OK, response.status()) + response = client.get(Routes.open) { + header(defaultHeader, "${apiKey}-wrong") } + assertEquals(HttpStatusCode.OK, response.status) } - } @Test @@ -53,20 +54,23 @@ class TestApiKeyAuth { validate { header -> header.takeIf { it == apiKey }?.let { ApiKeyPrincipal(it) } } } - withTestApplication(module) { - handleRequest(HttpMethod.Get, Routes.authenticated) { - addHeader(defaultHeader, apiKey) - }.apply { - assertEquals(HttpStatusCode.OK, response.status()) - val principal = receive() - assertEquals(principal, ApiKeyPrincipal(apiKey)) + testApplication { + application(module) + val client = createClient { install(ContentNegotiation) { jackson() } } + + // correct header + val response = client.get(Routes.authenticated) { + header(defaultHeader, apiKey) } + assertEquals(HttpStatusCode.OK, response.status) + val principal = response.body() + assertEquals(principal, ApiKeyPrincipal(apiKey)) - handleRequest(HttpMethod.Get, Routes.authenticated) { - addHeader(defaultHeader, "$apiKey-wrong") - }.apply { - assertEquals(HttpStatusCode.Unauthorized, response.status()) + // incorrect header + val unauthorizedResponse = client.get(Routes.authenticated) { + header(defaultHeader, "${apiKey}-wrong") } + assertEquals(HttpStatusCode.Unauthorized, unauthorizedResponse.status) } } @@ -80,18 +84,19 @@ class TestApiKeyAuth { val module = buildApplicationModule { headerName = header - challenge { call.respond(errorStatus) } + challenge { call -> call.respond(errorStatus) } validate { header -> header.takeIf { it == apiKey }?.let { ApiKeyPrincipal(it) } } } + testApplication { + application(module) + val client = createClient { install(ContentNegotiation) { jackson() } } - withTestApplication(module) { - handleRequest(HttpMethod.Get, Routes.authenticated) { - addHeader(header, apiKey) - }.apply { - assertEquals(HttpStatusCode.OK, response.status()) - val principal = receive() - assertEquals(principal, ApiKeyPrincipal(apiKey)) + val response = client.get(Routes.authenticated) { + header(header, apiKey) } + assertEquals(HttpStatusCode.OK, response.status) + val principal = response.body() + assertEquals(principal, ApiKeyPrincipal(apiKey)) } } @@ -103,24 +108,26 @@ class TestApiKeyAuth { val module = buildApplicationModule { headerName = header - challenge { call.respond(errorStatus) } + challenge { call -> call.respond(errorStatus) } validate { header -> header.takeIf { it == apiKey }?.let { ApiKeyPrincipal(it) } } } + testApplication { + application(module) + val client = createClient { install(ContentNegotiation) { jackson() } } - withTestApplication(module) { - handleRequest(HttpMethod.Get, Routes.authenticated) { - addHeader(header, apiKey) - }.apply { - assertEquals(HttpStatusCode.OK, response.status()) - val principal = receive() - assertEquals(principal, ApiKeyPrincipal(apiKey)) + // correct header + val response = client.get(Routes.authenticated) { + header(header, apiKey) } + assertEquals(HttpStatusCode.OK, response.status) + val principal = response.body() + assertEquals(principal, ApiKeyPrincipal(apiKey)) - handleRequest(HttpMethod.Get, Routes.authenticated) { - addHeader(header, "$apiKey-wrong") - }.apply { - assertEquals(errorStatus, response.status()) + // incorrect header + val unauthorizedResponse = client.get(Routes.authenticated) { + header(header, "${apiKey}-wrong") } + assertEquals(errorStatus, unauthorizedResponse.status) } } } diff --git a/src/test/kotlin/dev/forst/ktor/apikey/TestApplication.kt b/src/test/kotlin/dev/forst/ktor/apikey/TestApplication.kt index 7240ed1..424584d 100644 --- a/src/test/kotlin/dev/forst/ktor/apikey/TestApplication.kt +++ b/src/test/kotlin/dev/forst/ktor/apikey/TestApplication.kt @@ -1,21 +1,21 @@ package dev.forst.ktor.apikey -import io.ktor.application.Application -import io.ktor.application.ApplicationCall -import io.ktor.application.call -import io.ktor.application.install -import io.ktor.auth.Authentication -import io.ktor.auth.Principal -import io.ktor.auth.authenticate -import io.ktor.auth.principal -import io.ktor.features.ContentNegotiation import io.ktor.http.HttpStatusCode -import io.ktor.jackson.jackson -import io.ktor.response.respond -import io.ktor.routing.get -import io.ktor.routing.post -import io.ktor.routing.route -import io.ktor.routing.routing +import io.ktor.serialization.jackson.jackson +import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.call +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.Principal +import io.ktor.server.auth.authenticate +import io.ktor.server.auth.principal +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.response.respond +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import io.ktor.server.routing.routing import io.ktor.util.pipeline.PipelineContext const val apiKeyAuth = "api-key" diff --git a/src/test/kotlin/dev/forst/ktor/apikey/TestMinimalExampleApp.kt b/src/test/kotlin/dev/forst/ktor/apikey/TestMinimalExampleApp.kt index 7c37a6e..b002ce2 100644 --- a/src/test/kotlin/dev/forst/ktor/apikey/TestMinimalExampleApp.kt +++ b/src/test/kotlin/dev/forst/ktor/apikey/TestMinimalExampleApp.kt @@ -1,27 +1,28 @@ package dev.forst.ktor.apikey -import io.ktor.application.Application -import io.ktor.http.HttpMethod +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode -import io.ktor.server.testing.handleRequest -import io.ktor.server.testing.withTestApplication +import io.ktor.server.application.Application +import io.ktor.server.testing.testApplication import org.junit.jupiter.api.Test import kotlin.test.assertEquals class TestMinimalExampleApp { @Test fun `test minimal example app works as expected`() { - withTestApplication(Application::minimalExample) { - handleRequest(HttpMethod.Get, "/").apply { - assertEquals(HttpStatusCode.Unauthorized, response.status()) - } + testApplication { + application(Application::minimalExample) + + val unauthorizedResponse = client.get("/") + assertEquals(HttpStatusCode.Unauthorized, unauthorizedResponse.status) - handleRequest(HttpMethod.Get, "/") { - addHeader("X-Api-Key", "this-is-expected-key") - }.apply { - assertEquals(HttpStatusCode.OK, response.status()) - assertEquals("Key: this-is-expected-key", response.content) + val response = client.get("/") { + header("X-Api-Key", "this-is-expected-key") } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("Key: this-is-expected-key", response.bodyAsText()) } } } From f7386123463583a529c84e8852cf328cbe547880 Mon Sep 17 00:00:00 2001 From: Lukas Forst Date: Sun, 10 Apr 2022 13:18:10 +0200 Subject: [PATCH 2/2] make detekt happy --- .../kotlin/dev/forst/ktor/apikey/ApiKeyAuthenticationProvider.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/dev/forst/ktor/apikey/ApiKeyAuthenticationProvider.kt b/src/main/kotlin/dev/forst/ktor/apikey/ApiKeyAuthenticationProvider.kt index a749302..bba7afb 100644 --- a/src/main/kotlin/dev/forst/ktor/apikey/ApiKeyAuthenticationProvider.kt +++ b/src/main/kotlin/dev/forst/ktor/apikey/ApiKeyAuthenticationProvider.kt @@ -10,7 +10,6 @@ import io.ktor.server.auth.Principal import io.ktor.server.request.header import io.ktor.server.response.respond - /** * Represents an API Key authentication provider. */