Basic auth with Ktor
| |

Conquer Authentication with Ktor: Part 2 – Basic Authentication

Welcome to the latest chapter in our ongoing series about implementing authentication with Ktor. Having laid a solid foundation by covering the fundamentals of Ktor, we’re now prepared to delve into the realm of security protocols. In today’s post, we’re going to start with the simplest one – Basic Authentication. We’ll unpack how this protocol functions and guide you through its implementation using Ktor.

Remember, this is a hands-on tutorial. Feel free to check out the project and code along with me.

Table of Contents

What is Basic Authentication?

Basic Authentication is a simple and straightforward method for an HTTP user agent, a browser, to provide a username and password when making a request.

Here’s a step-by-step of how Basic Authentication works.

Basic auth protocol
Basic Authentication sends encoded user credentials to the server.
  1. Client sends a HTTP request: The client sends a request to the server without headers related to authentication.
  2. Server responds: If authentication is required, the server responds with a 401 Unauthorized status code and a WWW-Authenticate header. The header looks like this: WWW-Authenticate: Basic realm="User Visible Realm".
  3. Client asks User for credentials: The client (browser) displays a pop-up asking the user for their username and password.
  4. Client authenticates: The client then must provide a username and password to the server. These credentials are concatenated with a colon (username:password), then base64 encoded, and sent to the server with an Authorization header. The second request from the client to the server would look like this: Authorization: Basic base64encode(username:password).
  5. Authentication completes: If the credentials are correct, the server processes the request and sends a response to the client. If the credentials are incorrect, the server responds again with a 401 Unauthorized status code, and the client is prompted to provide valid credentials.

Please note that Basic Authentication transmits the username and password in an easily reversible format. Therefore, it’s critical that you only use Basic Authentication over HTTPS to encrypt your credentials during transit. Because of the simplicity and lack of advanced features (like token refresh, multi-factor authentication), Basic Authentication is typically suitable for straightforward use cases or for internal application communication.

Enable Basic Authentication in Ktor

There’s an authentication add-on in Ktor. You can choose the specific means of authentication or opt for an authentication bundle that contains everything. For the purpose of this tutorial, we’ll use the latter.

Ktor offers specialized authentication add-ons. You can either select a specific authentication method based on your needs or opt for the comprehensive authentication bundle that encompasses all available methods. For the purpose of this tutorial, we’ll go with the latter, providing us with the broadest scope of authentication methods to work with.

In the project’s build file (build.gradle.kts), add the following dependency.

implementation("io.ktor:ktor-server-auth-jvm")

In order to protect access to all the endpoints in our web application, we need to install the authentication add-on. Here is a simple setup taken from the documentation.

install(Authentication) {
    basic("auth-basic") {
        realm = "Access to the '/' path"
        validate { credentials ->
            if (credentials.name == "jetbrains" && credentials.password == "foobar") {
                UserIdPrincipal(credentials.name)
            } else {
                null
            }
        }
    }
}

Are we done? Not at all! Who would want to hard-code credentials anyway? Let me show you a better approach.

Store User Credentials

For the sake of this tutorial, let’s assume that user credentials are stored as plain text in a file. However, this is not a recommended practice in a production environment due to security concerns. In a real-world scenario, we should implement additional layers of security by hashing or encrypting stored values. For now, we will omit these complexities to keep our example straightforward. Nevertheless, it is not difficult to imagine how to enhance security measures on various types of resources, such as a text file or a relational database, in order to stay aligned with best practices in data security.

FIrst of all, let’s add a new section to application.conf.

    auth {
        basic {
            realm = "ktor"
            usersFile = "users.properties"
        }
    }
  • auth.basic: Serves as a root of our authentication config.
  • realm: This is the name of the protected resource. Feel free to call it whichever way you like.
  • usersFile: Relative path to the file with users’ credentials.

Next, we add users.properties containing usernames along with their respective passwords.

user1=secret1
user2=secret2
user3=secret3

Here is how src/main/resources folder should look like at this stage.

Your resource folder now contains a text file with user credentials.

Configure Security

Finally, we are ready to implement the validation of incoming requests against the stored credentials.

Note: The complete example is available on GitHub.

As a first step, we will create an extension method on Ktor’s Application object. This method allows us to easily read user credentials from the text file and store them in-memory as key-value pairs in a map.

fun Application.loadUsers(filePath: String): Map<String, String> {
    val userFile = this.javaClass.classLoader.getResource(filePath)
        ?: throw IllegalArgumentException("Could not read users file: $filePath")
    return userFile.readText().lines().associate {
        val (name, password) = it.split("=")
        name to password
    }
}

Next, we load and interpret application config.

fun Application.configureSecurity() {
    // The relevant part of application configuration is the following:
    val authConfig = environment.config.config("ktor.auth.basic")

    // The realm is the name of the protected area:
    val realm = authConfig.property("realm").getString()

    // Usernames and passwords are read from the configuration file:
    val usersFile = authConfig.property("usersFile").getString()

    // Load the users from the file. In this example, the file is a simple text file:
    val users = loadUsers(usersFile)
}

Finally, we update the configureSecurity extension method with request validation.

install(Authentication) {
    basic("auth-basic") {
        this.realm = realm
        validate { credentials ->
            if (credentials.name in users && 
                credentials.password == users[credentials.name]
            ) {
                UserIdPrincipal(credentials.name)
            } else {
               null
           }
        }
    }
}

Ktor will verify each incoming request in accordance with the conditions specified in the validate block. In this process, it extracts credentials from the Authorization header. Remember, these are base64 encoded. Ktor will decode them behind the scenes and construct the Credentials object for you.

UPDATE: This is the final version of the security config:

fun Application.configureSecurity() {
    // The relevant part of application configuration is the following:
    val authConfig = environment.config.config("ktor.auth.basic")

    // The realm is the name of the protected area:
    val realm = authConfig.property("realm").getString()

    // Usernames and passwords are read from the configuration file:
    val usersFile = authConfig.property("usersFile").getString()

    // Load the users from the file. In this example, the file is a simple text file:
    val users = loadUsers(usersFile)

    install(Authentication) {
        basic("auth-basic") {
            this.realm = realm
            validate { credentials ->
                if (credentials.name in users && credentials.password == users[credentials.name]) {
                    UserIdPrincipal(credentials.name)
                } else {
                    null
                }
            }
        }
    }
}

fun Application.loadUsers(filePath: String): Map<String, String> {
    val userFile = this.javaClass.classLoader.getResource(filePath)
        ?: throw IllegalArgumentException("Could not read users file: $filePath")
    val delimiter = "="
    userFile.readText().lines()
    return userFile.readText().lines().filter { it.contains(delimiter) }.associate {
        val (name, password) = it.split(delimiter)
        name to password
    }
}

Protect Routes with Basic Authentication

To protect the desired routes we wrap them in an authenticate block.

fun Application.configureRouting() {
    routing {
        authenticate("auth-basic") {
            get("/") {
                call.respondText("Hello World!")
            }
        }
    }
}

Run It!

Let’s now test our implementation and see how basic authentication works to protect our resources. In your browser, navigate to http://localhost:8080. Here’s what you should expect.

Your browser fires up a login pop-up when you visit a protected resource.

When you close the dialogue generated by your browser, the page won’t load.

The server denies access with HTTP 401 response.

Entering the correct credentials unlocks access to the protected resource.

Inspect Network Traffic

Let’s have a closer look at communication between the client (browser) and the server.

Initially, when visiting http://localhost:8080, the server responds with 401 Unauthorized. Additionally, using the Www-Authenticate header, it indicates the requirement on basic authentication. Note that the name of the protected realm is aligned with our security configuration.

Server denies access to anonymous visitors and indicates that basic authentication is required.

The browser understands the protocol and displays a login popup. After having entered your credentials, the browser sends them encoded back to the server in the Authorization header.

The browser attaches your credentials as base-64 encoded string.

Keep in mind that credentials transmitted in plain text can be easily decoded. To prevent attackers from compromising user credentials, it is essential to use basic authentication in conjunction with HTTPS.

echo dXNlcjE6c2VjcmV0MQ== | base64 --decode       
user1:secret1

Summary

Congratulations on completing this section of our hands-on tutorial. Today, you’ve gained an understanding of how basic authentication works. We’ve explored the negotiation process between the server and the browser, and discussed security risks related to transmitting user credentials essentially as plain text. Ktor greatly simplifies the process of incorporating basic authentication into a server. As we’ve seen enabling security with Ktor is easy and intuitive.

Thank you for reading! Stay tuned for our next episode, where we’ll enhance our minimalistic setup with form authentication.

Similar Posts

2 Comments

  1. The code you provide up to the headline “Run It!” generates an ApplicationConfigurationException: Property auth.basic.realm not found.

    1. Hi Jonathan,
      Thank you for letting me know! You’re right, the config path was wrong. It should be “ktor.auth.basic” as opposed to just “auth.basic”. Nevertheless, I’ve updated the article and included the full configuration. You can always check out the project from GitHub and run the complete example.

Comments are closed.