Beliebte Suchanfragen
//

Aufbau eines sicheren HTTP-Backends mit Kotlin und Ktor

24.3.2023 | 8 Minuten Lesezeit

Viele HTTP-Backends werden heutzutage mit Spring bzw. Spring Boot gebaut. So leistungsfähig Spring ist, so schwergewichtig ist das Framework auch. Außerdem liegt nicht jedem Developer die "Magie" in Spring Boot, die allein durch Annotationen Funktionalitäten zur Verfügung stellt. In diesem Artikel möchte ich als Alternative zeigen, wie man mit sehr wenig Aufwand ein abgesichertes HTTP-Backend auf Basis von Ktor erstellt.

Ktor ist eine leichtgewichtige Alternativezu Spring/Spring Boot, die meine Kollegen auch schon vorgestellt haben (Ein Microservice mit Kotlin und Ktor – ohne Spring und Ktor – Es muss nicht immer Spring sein). Das Framework wird von JetBrains entwickelt und offiziell unterstützt. Mittlerweile in Version 2.X ist Ktor gut für den produktiven Einsatz bereit. Besonders für Ktor spricht, dass der Code, den man schreiben muss, kurz und leicht verständlich ist.

Die Dokumentation ist weitgehend hervorragend und erleichtert den Einstieg. Allerdings sind fast alle Beispiele in der Dokumentation auf das Einsatzszenario ausgerichtet, dass Ktor auch das Anwendungs-Frontend in HTML per Server Side Rendering (SSR) ausspielt.

In unserer Praxis ist es allerdings häufig so, dass als Frontend eine Single-Page-App (SPA) oder vielleicht eine Mobile-App zum Einsatz kommt und sie mit dem Backend per HTTP/REST-Calls kommuniziert. Gerade im Bereich Authentication und Security passen daher die Beispiele nur bedingt.

Das Beispielprojekt

In diesem Artikel bauen wir zusammen ein Ktor-Backend auf, das folgende Anforderungen erfüllt:

  • Es kommuniziert mit dem Frontend im JSON-Format.
  • Das Frontend (das wir nicht bauen werden) soll dann eine Login-Maske bereitstellen und die dort eingegebenen Daten per POST als Body an einen HTTP-Endpunkt schicken.
  • Als Authentication Source wird ein LDAP-Server genutzt.
  • Die Session-ID wird im Falle einer erfolgreichen Authentifizierung dem Client als Session-Cookie übergeben. Die Session-Daten befinden sich ausschließlich auf dem Server.
  • Es gibt gesicherte Endpunkte, die nur zugänglich sind, wenn der Client mit einem gültigen Session-Cookie zugreift. Diese haben ihrerseits Zugriff auf die in der Session gespeicherten Daten.
  • Möchte sich der Client ausloggen, kann er einen Endpunkt aufrufen, der die Session invalidiert und löscht. Ab hier kann man auch mit dem vorher noch gültigen Session-Cookie nicht mehr auf die gesicherten Endpunkte zugreifen.

Wenn eine solche Kommunikation über HTTPS abläuft, sind viele Sicherheitslücken geschlossen. Leicht kann man die Lösung noch um ein CSRF-Token erweitern, das ist aber nicht Inhalt dieses Beispiels.

Den Code für diesen Artikel findet ihr in Github. Es ist allerdings sinnvoller, wenn ihr diesen Artikel als Tutorial anseht und die einzelnen Schritte selbst durchführt.

Fangen wir an:

Ktor-Projekt aufsetzen

Ein neues Ktor-Projekt erstellt man in am einfachsten in IntelliJ, da die IDE dafür ein Projekt-Template bereithält:

Bei Configuration in: wählt man am besten Yaml file oder HOCON file, aber nicht Code. Das hat zu Folge, dass ein Properties-File erzeugt wird, in das man später noch weitere Anwendungsparameter zur Konfiguration einfügen kann.

Auf der nächsten Seite wählt ihr folgende Module:

  • kotlinx.serialization
  • Sessions
  • Authentication LDAP

Der Wizard wird von sich aus noch weitere Module hinzufügen:

  • Routing
  • Content Negotiation
  • Authentication

So gerüstet kann man auf Create klicken.

Session einrichten

Der Wizard hat einige Dateien erzeugt. Wir kümmern uns im Folgenden insbesondere um Security.kt im Package de.codecentric.plugins. Den Inhalt der Funktion configureSecurity könnt ihr löschen, den schreiben wir komplett neu. Als erstes schreiben wir diesen Code in die Funktion:

1install(Sessions) {
2        cookie<SessionContent>("KTOR_SESSION", SessionStorageMemory()) {
3            cookie.path = "/"
4            cookie.extensions["SameSite"] = "lax"
5        }
6    }

install dient immer dazu, ein „Plugin“ (aka ein „Module“) zu aktivieren und damit den Funktionsumfang von Ktor zu erweitern. Das Plugin Sessions ist so eine Erweiterung.

In dem Lambda geben wir an:

  • dass die Session über Cookies gehalten wird und dass der Inhalt der Session die Klasse SessionContent ist – diese Klasse kommt gleich.
  • dass die Sessiondaten im Backend gehalten werden (und nicht im Cookie selbst) und zwar mittels eines SessionStorageMemory-Objekts.
  • wie der Cookie aussehen soll.

Wir ergänzen noch die Klasse für die Session-Daten:

1data class SessionContent(
2  val user: String
3) : Principal

Jetzt kann Ktor schon mal Sessions anlegen, wenn man darum bittet. Genau das wollen wir im nächsten Schritt tun.

Authentication einrichten

Wir möchten einzelne Routen nur dann bedienen, wenn in der Session ein gültiger User eingetragen ist. Dazu ergänzen wir den folgenden Code hinter dem install-Code, den wir gerade geschrieben haben:

1authentication {
2        session<SessionContent> {
3            validate { it }
4
5            challenge {
6                call.sessions.clear<SessionContent>()
7                call.respond(HttpStatusCode.Forbidden, "not authorized")
8            }
9        }
10    }

Dieses Plugin müssen wir nicht explizit mittels install installieren. Stattdessen liefert es eine Helper-Function namens authentication mit, die das Plugin implizit installiert.

Im Lambda geben wir an, dass die authentication über die session geschieht und dass als Principal eben jedes SessionContent-Objekt genutzt werden soll. Daher hatten wir SessionContent auch von Principal abgeleitet.

Im Lambda gibt es zwei weitere Funktionen:

validate muss ein gültiges Principal zurückgeben. Da wir dieses bereits als Inhalt der Session benutzen (it) reicht es, das zurückzugeben. Wenn man mit Gültigkeitszeiträumen von Sessions arbeiten möchte, könnte man die hier prüfen.

challenge wird aufgerufen, wenn validate kein gültiges Principal zurückliefern kann. In diesem Fall löschen wir sicherheitshalber eine etwaige Session und antworten mit einem HTTP-Status 403.

Jetzt können wir bereits einzelne Routen absichern. Um das zu prüfen, erstellen wir eine Route, die nur aufgerufen werden kann, wenn der Request authentifiziert ist.

1routing {
2        authenticate {
3            route("/api") {
4                get("/who-am-i") {
5                    call.principal<SessionContent>()?.let {
6                        call.respond(WhoAmIResponse.fromPrincipal(it))
7                    }
8                }
9            }
10        }
11    }

routing sagt, dass wir Ktor jetzt Endpunkt-Routen bekannt machen. Durch authenticate wird der Endpunkt abgesichert. Der Code des Endpunkts (get) holt dann nur das Principal und gibt es in ein WhoAmIResponse-Objekt verpackt wieder zurück.

1@Serializable
2data class WhoAmIResponse(val user: String) {
3    companion object {
4        fun fromPrincipal(session: SessionContent) = WhoAmIResponse(session.user)
5    }
6}

So, fertig. Jetzt müssen wir "nur noch" dafür sorgen, dass sich jemand anmelden und damit eine gültige Session erzeugen kann. Dafür nutzen wir öffentliche Endpunkte, die nicht abgesichert sind. Der folgende Code ergänzt unser routing-Lambda, kann aber auch in einem separaten routing-Aufruf stecken.

1route("/public") {
2            post("/login") {
3                val loginRequestBody = call.receive<LoginRequest>()
4                val correct = validateLocally(loginRequestBody)
5                if (correct) {
6                    call.sessions.set(SessionContent(loginRequestBody.username))
7                    call.respond(HttpStatusCode.OK)
8                } else {
9                    call.respond(HttpStatusCode.Forbidden)
10                }
11            }
12
13            post("invalidate-session") {
14                call.sessions.clear<SessionContent>()
15                call.respond(HttpStatusCode.OK)
16            }
17        }

Der Endpunkt /public/api holt aus dem Body ein LoginRequest-Objekt, das username und password enthält. Die werden in einer externen Funktion validateLocally geprüft. Wenn sie gültig sind, wird ein SessionContent-Objekt in die Session geschrieben und der Aufruf war erfolgreich. Sonst kommt ein HTTP-Status 403.

1@Serializable
2data class LoginRequest(val username: String, val password: String)

Natürlich brauchen wir jetzt auch eine Prüffunktion:

1private fun validateLocally(loginRequestBody: LoginRequest) =
2    loginRequestBody.username == "Philip J. Fry"
3        && loginRequestBody.password == "fry"

(Warum wir gerade diese Login-Informationen als gültig ansehen, erkläre ich weiter unten).

Fertig. Wenn wir den Server jetzt starten, kann man sich mit dem User "Philip J. Fry" und dem Password "fry" einloggen, einen geschützten Endpunkt aufrufen, der ohne gültige Session nicht erreichbar ist.

Testet das gerne mit Postman – oder irgend einem anderen HTTP-Client.

Aber wir können noch mehr!

Authentifizierung per LDAP

Ein fest implementierter User ist natürlich nicht schön. Stattdessen hatten wir ja gesagt, dass die Credentials gegen ein LDAP geprüft werden sollen (wir laden zunächst keine weiteren Daten aus dem LDAP).

Ein solches LDAP kann man leicht mittels docker compose up -d und der folgenden docker-compose.yml starten:

1version: '3.5'
2
3services:
4  ldap-aquar:
5    container_name: ldap-aquar
6    image: rroemhild/test-openldap
7    ports:
8      - "10389:10389"
9      - "10636:10636"
10    networks:
11      - local-network
12
13networks:
14  local-network:
15    driver: bridge

In diesem LDAP sind etliche User aus Futurama hinterlegt, zum Beispiel jener "Philip J. Fry".

Die Anbindung an das LDAP geschieht in einer alternativen validate…-Funktion:

1private fun validateInLDAP(loginRequestBody: LoginRequest): Boolean {
2    val user = ldapAuthenticate(
3        UserPasswordCredential(loginRequestBody.username, loginRequestBody.password),
4        "ldap://localhost:10389",
5        "cn=%s,ou=people,dc=planetexpress,dc=com"
6    )
7
8    return user != null
9}

ldapAuthenticate ist eine Funktion, die von dem Authentication LDAP-Modul geliefert wird. Sie liefert im Erfolgsfall ein User-Objekt zurück. Wir machen damit nicht mehr, als dass wir sicherstellen, dass dieses Objekt nicht null ist.

Sinnvollerweise sollten die Konfigurationswerte (URL, Port und auch der Query-String) in die application.yml ausgelagert werden, aber das habe ich mir gespart, um das Beispiel kurz zu halten.

In dem Post-Enpunkt muss nur noch diese Validation-Funktion genutzt werden:

1post("/login") {
2    val loginRequestBody = call.receive<LoginRequest>()
3    val correct = validateInLDAP(loginRequestBody)
4    ...

Testen

Wir wollen unseren Code natürlich auch testen. Dafür legen wir neben den vordefinierten Test einen neuen Test an und nennen ihn z. B.: SecurityTest.

Einen Ktor-Test kann man mithilfe der helper function testApplication schreiben. Im Lambda ist dann ein client verfügbar, mit dem ich meine Endpunkte aufrufen kann. Für "normale" Routen reicht das, in unserem Fall müssen wir aber mehr machen. Der Client muss json sprechen, außerdem müssen wir ihm beibringen, dass er den Session-Cookie bei Requests mitschicken soll.

Damit das geht, muss ich noch folgende Dependency in die build.gradle.kts hinzufügen:

1testImplementation("io.ktor:ktor-client-content-negotiation-jvm:$ktor_version")

Dann kann ich mir eine eigene helper function schreiben:

1fun clientTest(testCode: suspend ApplicationTestBuilder.(HttpClient) -> Unit) =
2    testApplication {
3        val client = createClient {
4            install(ContentNegotiation) { json() }
5            install(HttpCookies)
6        }
7
8        testCode(client)
9    }

Diese Funktion nimmt ein Lambda als Parameter und erzeugt selbst die testApplication. Darin wird ein eigener client erzeugt, der json spricht und HttpCookies kennt. Das übergebene Lambda wird dann mit diesem client als Parameter aufgerufen.

Ein Test sieht dann so aus:

1@Test
2    fun `should not be able to call who-am-i without login first`() = clientTest { client ->
3        client.get("/api/who-am-i").let {
4            assertEquals(HttpStatusCode.Forbidden, it.status)
5        }
6    }

Ich rufe – ohne Anmeldung – den gesicherten Endpunkt auf und stelle fest, dass ich einen HTTP-Status FORBIDDEN erhalte.

Der Positiv-Test ist ähnlich schnell geschrieben:

1@Test
2    fun `should login successfully`() = clientTest { client ->
3
4        client.post("/public/login") {
5            contentType(ContentType.Application.Json)
6            setBody(LoginRequest("Philip J. Fry", "fry"))
7        }.let {
8            assertEquals(HttpStatusCode.OK, it.status)
9        }
10
11        client.get("/api/who-am-i").let {
12            assertEquals(HttpStatusCode.OK, it.status)
13            assertEquals(WhoAmIResponse("Philip J. Fry"), it.body())
14        }
15    }

Im ersten Schritt logge ich mich ein, und erhalte einen HTTP-Status OK. Danach kann ich auch den gesicherten Endpunkt aufrufen und kann das Ergebnis überprüfen.

Wenn man jetzt noch mehr Tests schreiben möchte, die einen angemeldeten Zustand benötigen, ist es sinnvoll, für den Login-Step eine eigene helper function (z. B.: authenticatedTest) zu schreiben. Der o. g. Test sähe dann nur noch so aus:

1@Test
2    fun `should login successfully`() = authenticatedTest("Philip J. Fry", "fry") { client ->
3        client.get("/api/who-am-i").let {
4            assertEquals(HttpStatusCode.OK, it.status)
5            assertEquals(WhoAmIResponse("Philip J. Fry"), it.body())
6        }
7    }

Ich überlasse es euch, diesen Helper zu schreiben …

Zusammenfassung und Ausblick

Mit diesem wenigen Code kann man abgesicherte HTTP-Endpunkte aufbauen und sehr leicht testen. Das Arbeiten mit Ktor geht schnell, ist übersichtlich und – wie ich finde – sehr verständlich.

Der Code ist sicher noch verbesserungsfähig, vor allem ist schlecht, dass der Server die Session-Daten im Speicher hält. Das skaliert nicht, und außerdem überlebt eine Session den Neustart des Servers nicht. Aber das ist ein Thema für einen weiteren Artikel.

Beitrag teilen

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.