Build A 2FA Server With Kotlin and Ktor

igorwojda

Igor Wojda

Posted on March 1, 2021

Build A 2FA Server With Kotlin and Ktor

Alt Text

In this tutorial, you will write a server that provides an API for Two-factor Authentication (2FA). This API will allow desktop clients, mobile clients, and web clients to utilize two-factor authentication.

To build the application, you will use the Kotlin language and Ktor, an asynchronous framework for creating microservices and web applications.

The complete source code is available on GitHub.

Prerequisites

To follow along with this tutorial, you will need:

  • IntelliJ IDEA IDE installed (paid or free, community edition).
  • Ktor plugin for IntelliJ IDEA. This plugin allows you to create a Ktor project using a new project wizard. Open IntelliJ IDEA, go to Preferences, then Plugins, and install a Ktor plugin from the marketplace.

Create A Ktor Project

  • Open IntelliJ IDEA, then go to File > New > Project.
  • In the New Project window, select the Ktor project on the left side and press the Next button.
  • On the next screen, leave the default values and press the Next button.
  • On the final screen, enter ktor-2fa-server as the application name and press the Finish button.

You have created a Ktor application project.

First endpoint

Open the src/Application.kt file and add a new routing to verify that the application is working:

fun Application.module(testing: Boolean = false) {
    routing {
        get("/") {
            call.respondText("2FA app is working", ContentType.Text.Html)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this tutorial, all the Ktor application code will be stored in the Application.kt file.

Click on the green arrow next to the main function to run the application (this will create a new run configuration in the IDE):

Run app

Navigate to http://localhost:8080/ in your browser to test if the application is working correctly—"2FA app is working" should be displayed:

App is working

Set Developement Mode

Enabling development mode allows the Ktor application to display more detailed debugging information in the IDE, such as call-stack. It will help with development and diagnosing issues.

Open the resources/application.conf file and add development = true:

ktor {
    development = true

    ...
Enter fullscreen mode Exit fullscreen mode

Add Dependencies

Vonage Java SDK

The Kotlin language provides interoperability with Java, which allows you to call Java code from Kotlin code so that you can use Vonage Java SDK for the Kotlin/Ktor project.

Open the build.gradle file and add the following dependency:

dependencies {

    ...

    implementation 'com.vonage:client:6.1.0'
}
Enter fullscreen mode Exit fullscreen mode

Serialization

You will use JSON as a data format to communicate with the clients. You will serialize Kotlin objects using Kotlin serialization.

Open the build.gradle file and add the following dependencies:

dependencies {

    ...

    implementation "io.ktor:ktor-serialization:$ktor_version"
    implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1'
}
Enter fullscreen mode Exit fullscreen mode

The Kotlin serialization library uses preprocessing (at compile time), so you have to add the org.jetbrains.kotlin.plugin.serialization Gradle plugin. At the time of writing this article, Ktor is using using the old way of applying Gradle plugins, so we have to replace it with the new configuration.

Open the build.gradle file and remove plugins:

apply plugin: 'kotlin'
apply plugin: 'application'
Enter fullscreen mode Exit fullscreen mode

Remove the mainClassName:

mainClassName = "io.ktor.server.netty.EngineMain"
Enter fullscreen mode Exit fullscreen mode

Remove the classpath:

dependencies {
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
Enter fullscreen mode Exit fullscreen mode

Add plugins using the new Gradle syntax, just below buildscript block:

buildscript {
    // ...
}

plugins {
    id "java"
    id "org.jetbrains.kotlin.jvm" version "$kotlin_version"
    id "org.jetbrains.kotlin.plugin.serialization" version "$kotlin_version"
}
Enter fullscreen mode Exit fullscreen mode

After all the modifiations, the build.gradle file should look like this:

buildscript {
    repositories {
        jcenter()
    }

    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

plugins {
    id "java"
    id "org.jetbrains.kotlin.jvm" version "$kotlin_version"
    id "org.jetbrains.kotlin.plugin.serialization" version "$kotlin_version"
}

group 'com.example'
version '0.0.1'

sourceSets {
    main.kotlin.srcDirs = main.java.srcDirs = ['src']
    test.kotlin.srcDirs = test.java.srcDirs = ['test']
    main.resources.srcDirs = ['resources']
    test.resources.srcDirs = ['testresources']
}

repositories {
    mavenLocal()
    jcenter()
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    implementation "io.ktor:ktor-server-netty:$ktor_version"
    implementation "ch.qos.logback:logback-classic:$logback_version"
    testImplementation "io.ktor:ktor-server-tests:$ktor_version"

    implementation 'com.vonage:client:6.1.0'
    implementation "io.ktor:ktor-serialization:$ktor_version"
    implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1'
}
Enter fullscreen mode Exit fullscreen mode

The kotlin_version and ktor_version properties are defined inside gradle.properties file.

To enable serializatin, the JSON Converter has to be enabled for the Ktor application. Open the Application.kt file and add an install block inside Application.module function:

fun Application.module(testing: Boolean = false) {

    install(ContentNegotiation) {
        json()
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

The IDE will mark all classes and extensions that have import missing with the red color. Rollover on the class or method name, wait for a window to appear, and select import... to add class import and fix the error.

Create a Vonage Application

A Vonage application will provide 2FA capabilities for the API. Create a Vonage application in the dashboard. Click the Create a new application button, enter a name, and click the Generate new application button.

Go to settings and make a note of API key and API secret.

Initialize Vonage Client

Add the client property inside Application.module function to initialize a Vonage client:

fun Application.module(testing: Boolean = false) {

    val client: VonageClient = VonageClient.builder()
        .apiKey("API_KEY")
        .apiSecret("API_SECRET")
        .build()

    install(ContentNegotiation) {
        json()
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Replace API_KEY and API_SECRET using the values from the dashboard.

NOTE: in production API_KEY and API_SECRET shuld be retrieved from environment variables.

API Functionality

You will build two API endpoints:

  • verifyNumber - the client will first hit this endpoint to start the verification process by processing the phone number to be verified.
  • verifyCode - after receiving code (via SMS or voice call), the client will send the code, and the application will perform a 2FA check to determine if the client is verified.

Create verifyNumber API Endpoint

Define a new route handler, get("/verifyNumber"), inside the routing block of the Application.module function:

fun Application.module(testing: Boolean = false) {

    // ...

    routing {
        get("/") {
            call.respondText("2FA app is working", ContentType.Text.Html)
        }
        get("/verifyNumber") {
            // ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The code within the get("/verifyNumber") route handler will be executed when the client makes a call to the http://localhost:8080/verifyNumber URL.

The verifyNumber endpoint will contain the following logic:

  • retrieve phoneNumber parameter from the query string (http://localhost:8080/verifyNumber?phoneNumber=1234)
  • start 2FA verification using the Vonage SDK
  • return requestId as a JSON (in a production application, you would typically store ID on the server-side)

Add the following logic to the get("/verifyNumber") route handler:

get("/verifyNumber") {
    val phoneNumber = call.parameters["phoneNumber"]
    require(!phoneNumber.isNullOrBlank()) { "phoneNumber is missing" }

    val ongoingVerify = client.verifyClient.verify(phoneNumber, "VONAGE")

    val response = VerifyNumberResponse(ongoingVerify.requestId)
    call.respond(response)
}
Enter fullscreen mode Exit fullscreen mode

Define a VerifyNumberResponse class that will be serialized to JSON and returned to the API client. Add the following code at the end of Application.kt file:

@Serializable
data class VerifyNumberResponse(val requestId: String)
Enter fullscreen mode Exit fullscreen mode

Kotlin allows defining multiple top-level members (classes, properties, etc.) within a single file.

Due to a bug in the Kotlin plugin, you need to add the import statement for Serializable annotation manually. Add the following code at the top of the file, just below the last import statement:

import kotlinx.serialization.Serializable
Enter fullscreen mode Exit fullscreen mode

Instead of using Vonage build-in verification, you could generate the code by yourself and send an SMS using Vonage Java SDK. However, the Vonage verification mechanism provides an easy way to use more complex workflows, e.g.: default workflow will make a phone call and read the code to the user if the client did not provide SMS code within a specific period.

Create verifyCode API Endpoint

Define a new route handler, get("/verifyCode"), inside the routing block of the Application.module function:

fun Application.module(testing: Boolean = false) {

    // ...

    routing {
        // ...
        get("/verifyCode") {
            // ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The verifyCode endpoint will contain the following logic:

  • retrieve code parameter from the query string (code will be delivered to the user after hitting the verifyNumber endpoint)
  • retrieve a verification requestId parameter from the query string (value retrieved from verifyNumber endpoint)
  • verify code using Vonage SDK
  • return verification status to the client

Add the following logic to the get("/verifyCode") route handler:

get("/verifyCode") {
    val code = call.parameters["code"]
    val requestId = call.parameters["requestId"]

    val checkResponse = client.verifyClient.check(requestId, code)
    println(checkResponse.status)

    val status = if(checkResponse.status == VerifyStatus.OK) {
        "OK"
    } else {
        "ERROR: ${checkResponse.status}"
    }

    val response = VerifyCodeResponse(status)
    call.respond(response)
}
Enter fullscreen mode Exit fullscreen mode

Define a VerifyCodeResponse class that will be serialized to JSON and returned to the API client. Add the following code at the end of Application.kt file:

@Serializable
data class VerifyCodeResponse(val status: String)
Enter fullscreen mode Exit fullscreen mode

After all the modifications, Application.kt file should look like this:

package com.example

import com.vonage.client.VonageClient
import com.vonage.client.verify.VerifyStatus
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.serialization.*
import kotlinx.serialization.Serializable

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {

    val client: VonageClient = VonageClient.builder()
        .apiKey("API_KEY")
        .apiSecret("API_KEY")
        .build()

    install(ContentNegotiation) {
        json()
    }

    routing {
        get("/") {
            call.respondText("2FA app is working", ContentType.Text.Html)
        }
        get("/verifyNumber") {
            val phoneNumber = call.parameters["phoneNumber"]
            require(!phoneNumber.isNullOrBlank()) { "phoneNumber is missing" }

            val ongoingVerify = client.verifyClient.verify(phoneNumber, "VONAGE")
            val response = VerifyNumberResponse(ongoingVerify.requestId)
            call.respond(response)
        }
        get("/verifyCode") {
            val code = call.parameters["code"]
            val requestId = call.parameters["requestId"]

            val checkResponse = client.verifyClient.check(requestId, code)
            println(checkResponse.status)

            val status = if(checkResponse.status == VerifyStatus.OK) {
                "OK"
            } else {
                "ERROR: ${checkResponse.status}"
            }

            val response = VerifyCodeResponse(status)
            call.respond(response)
        }
    }
}

@Serializable
data class VerifyNumberResponse(val requestId: String)

@Serializable
data class VerifyCodeResponse(val status: String)
Enter fullscreen mode Exit fullscreen mode

Use the API

The API implementation is complete, so let's test it.

Any client can use the API, including desktop and mobile clients, but you will perform simple testing by using a web browser.

Launch the Ktor application.

Replace PHONE_NUMBER with an actual phone number and open the following URL in the browser:

http://localhost:8080/verifyNumber?phoneNumber=PHONE_NUMBER
Enter fullscreen mode Exit fullscreen mode

Vonage phone numbers are in E.164 format, '+' and '-' are not valid. Make sure you specify your country code when entering your number, for example, US: 14155550100 and UK: 447700900001

As a trial user, you will only be able to send SMS and make voice calls to the number you registered with and up to 4 other test numbers of your choice (you can top up your Vonage account to remove this restriction).

You should receive an SMS with a code and see a similar response:

{"requestId":"9ac76db7971b4ea4a49f2e061432c6fe"}
Enter fullscreen mode Exit fullscreen mode

Compose a second request. Replace REQUEST_ID with the value returned from server (in the above example, it's 9ac76db7971b4ea4a49f2e061432c6fe) and replace CODE with the received verification code:

http://localhost:8080/verifyCode?requestId=REQUEST_ID&code=CODE
Enter fullscreen mode Exit fullscreen mode

If the client phone number is verified, you should see the following response:

{"status":"OK"}
Enter fullscreen mode Exit fullscreen mode

You are using a default Vonage verification workflow (https://developer.nexmo.com/verify/guides/workflows-and-events), so if you do not enter the code within 125 seconds, you will receive the voice call reading the code.

Further Reading

You can find the code shown in this tutorial on the Github.

Below are a few other tutorials we've written either involving using our services with Go:

If you have any questions, advice, or ideas you'd like to share with the community, please feel free to jump on our Community Slack workspace. I'd love to hear back from anyone that has implemented this tutorial and how your project works.

💖 💪 🙅 🚩
igorwojda
Igor Wojda

Posted on March 1, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related