Ktor Server Pro course is out 🔥Start learning today

Secure REST API with Ktor – Role-based access control (RBAC)

In this, 3rd article in a series, I will show you how to add role-based access control (RBAC / role-based security) to our Ktor project.
This image is a featured image for article titled "Secure REST API with Ktor - Role-based access control (RBAC)" and consist of a Ktor logo in the foreground and a blurred photo of city at night in the background.

Hello and welcome to the 3rd article in our secure REST API with Ktor series, in which I will show you how to set up role-based access control (RBAC), also known as role-based security.

If you haven’t seen my previous articles, then I highly encourage you to check them out:

Moreover, in this tutorial, we will build functionality based on the refresh token article, so I highly encourage you to fetch the code from this repo now. But don’t worry, if you are interested only in the RBAC part, then please navigate to the “Custom Ktor Authorization Plugin” paragraph.

Video Tutorial

If you prefer video content, then check out my video:

If you find this content useful, please leave a subscription  😉

What Exactly Will We Do Today?

At the end of this tutorial, you will know precisely how to secure your Ktor REST API with role-based access control (RBAC).

But to be more specific, we will work with two endpoints:

Image shows two endpoint - get all users and get user by ID which will be secured with RBAC

The GET /api/user/{id} will be accessible for users with either ADMIN or USER roles. The second one, GET /api/user, will be accessible only for ADMIN users.

And how we will achieve that?

Well, we will implement a custom Ktor authorization plugin, which will be triggered on the AuthenticationChecked hook.

And I’ll show you how to do it the easy way, without unnecessary logic 🙂

What Is Role-Based Access Control (RBAC)?

Again, before we learn how to implement role-based access control (RBAC) in Ktor, let’s learn a bit about it.

Role-based access control (RBAC) or role-based security is nothing else than a way of restricting system access to authorized users.

To put it simply, we define a set of roles in our system, which reflects the needs of given permissions from our organization. An example of such a role can be a user, manager, admin, and many many more. And based on this role we allow, or deny access for a particular, authenticated user.

When dealing with JWT tokens in a REST API, such information can be first stored inside the token, so that later, we can easily read a claim and check whether a request should be served, or not.

Update User Class and Repo

With all of that said, let’s finally start the practice part.

As the first step, let’s navigate to the User class and insert a new field- role:

import java.util.UUID

data class User(
  val id: UUID,
  val username: String,
  val password: String,
  val role: String
)

For the sake of simplicity, let’s leave it as a String value, but depending on your needs, you may want to consider using an enum, or even a sealed class instead.

At this point, our code won’t compile, so let’s navigate to the UserRoute.kt and update the .toModel() extension function:

private fun UserRequest.toModel(): User =
  User(
    id = UUID.randomUUID(),
    username = this.username,
    password = this.password,
    role = "USER"
  )

As we can see, by default, all created users will have the USER role assigned.

Lastly, let’s navigate to the UserRepository and add some ADMIN user:

class UserRepository {

  private val users = mutableListOf(
    User(UUID.randomUUID(), "admin", "password", "ADMIN")
  )

  // the rest of the class
}

Add Role Claim to JWT Token

As the next step, let’s open up the JwtService and update these 3 functions:

fun createAccessToken(username: String, role: String): String =
  createJwtToken(username, role, 3_600_000)

fun createRefreshToken(username: String, role: String): String =
  createJwtToken(username, role, 86_400_000)

private fun createJwtToken(
  username: String,
  role: String, 
  expireIn: Int,
): String =
  JWT.create()
    .withAudience(audience)
    .withIssuer(issuer)
    .withClaim("username", username)
    .withClaim("role", role)
    .withExpiresAt(Date(System.currentTimeMillis() + expireIn))
    .sign(Algorithm.HMAC256(secret))

As I mentioned in the “theory” part- when dealing with JWT tokens, we can put information about the user’s role inside the token.

And that’s exactly what is happening here. From now on, every access and refresh token will contain an additional claim- the role– which will be populated with a role assigned to a user.

Update UserService

With that done, we must navigate to the UserService and update authenticate and refreshToken functions:

fun authenticate(loginRequest: LoginRequest): AuthResponse? {
  val username = loginRequest.username
  val foundUser: User? = userRepository.findByUsername(username)

  return if (foundUser != null && loginRequest.password == foundUser.password) {
    val accessToken = jwtService.createAccessToken(username, foundUser.role)
    val refreshToken = jwtService.createRefreshToken(username, foundUser.role)

    refreshTokenRepository.save(refreshToken, username)

    AuthResponse(
      accessToken = accessToken,
      refreshToken = refreshToken,
    )
  } else
    null
}

fun refreshToken(token: String): String? {
  val decodedRefreshToken = verifyRefreshToken(token)
  val persistedUsername = refreshTokenRepository.findUsernameByToken(token)

  return if (decodedRefreshToken != null && persistedUsername != null) {
    val foundUser: User? = userRepository.findByUsername(persistedUsername)
    val usernameFromRefreshToken: String? = decodedRefreshToken.getClaim("username").asString()

    if (foundUser != null && usernameFromRefreshToken == foundUser.username)
      jwtService.createAccessToken(persistedUsername, foundUser.role)
    else
      null
  } else
    null
}

From now on we must pass the user role to the JwtService when creating new tokens and that’s exactly what we updated here.

Custom Ktor Authorization Plugin

What Are Ktor Plugins?

Before we implement our custom one, let’s understand what exactly Ktor plugins are.

Basically, plugins in Ktor are a way to implement a common functionality that is out of the scope of the application logic. But what do we mean by that? Well, things like serialization, deserialization, compression, encoding, etc.

And when we take a look at the following diagram:

We will clearly see that plugins are everything that sits between request, application handler, and response. And yes, routing is a plugin too.

At this point, we can see that a custom plugin will be a great wait to achieve RBAC in our Ktor application. Moreover, we will use a new simplified API for creating custom plugins introduced in Ktor v2.0.0.

Implement Ktor RBAC Plugin

With all of that said, let’s navigate to the plugins package and create the RoleBasedAuthorization.kt and write the following:

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.response.*

class PluginConfiguration {
  var roles: Set<String> = emptySet()
}

val RoleBasedAuthorizationPlugin = createRouteScopedPlugin(
  name = "RbacPlugin",
  createConfiguration = ::PluginConfiguration
) {
  val roles = pluginConfig.roles

  pluginConfig.apply {

    on(AuthenticationChecked) { call ->
      val tokenRole = getRoleFromToken(call)

      val authorized = roles.contains(tokenRole)

      if (!authorized) {
        println("User does not have any of the following roles: $roles")
        call.respond(HttpStatusCode.Forbidden)
      }
    }
  }
}

private fun getRoleFromToken(call: ApplicationCall): String? =
  call.principal<JWTPrincipal>()
    ?.payload
    ?.getClaim("role")
    ?.asString()

Firstly, we create the PluginConfiguration class with a roles field. This way, we will be able to configure later, which roles should be allowed for the given endpoint and which should be forbidden.

Later, we introduce our new, custom Ktor plugin using the createRouteScopedPlugin function. We must do that if we want to introduce a plugin that can be installed for a specific route.

Inside this function, we refer to pluginConfig and configure what exactly our custom authorization plugin must do. Firstly, we set the on handler that accepts a Hook as a parameter- in our case the AuthenticationChecked is a hook. As per the documentation:

AuthenticationChecked is executed after authentication credentials are checked.

Which simply means, that our plugin will be invoked after we authenticate the user successfully. With the old API, we would have to use the after Authentication.ChallengePhase.

Following, we extract the user role from the JWT role claim and if it is inside the Set with allowed roles, then we do nothing. Otherwise, we return 403 Forbidden.

Add RoleUtil

With that done, let’s navigate to the util package and introduce the authorized function inside the RouteUtil.kt:

import com.codersee.plugins.RoleBasedAuthorizationPlugin
import io.ktor.server.application.*
import io.ktor.server.routing.*

fun Route.authorized(
  vararg hasAnyRole: String,
  build: Route.() -> Unit
) {
  install(RoleBasedAuthorizationPlugin) { roles = hasAnyRole.toSet() }
  build()
}

To put it simply, we will use this function whenever we would like to authorize our request using role-based access control.

As a result, it will register our authorization plugin for a particular route.

Update User Routes

And with that said, let’s see it in action:

authenticate {
  authorized("ADMIN") {

    get {
      val users = userService.findAll()

      call.respond(
        message = users.map(User::toResponse)
      )
    }

  }
}

authenticate("another-auth") {
  authorized("ADMIN", "USER") {

    get("/{id}") {
      val id: String = call.parameters["id"]
        ?: return@get call.respond(HttpStatusCode.BadRequest)

      val foundUser = userService.findById(id)
        ?: return@get call.respond(HttpStatusCode.NotFound)

      if (foundUser.username != extractPrincipalUsername(call))
        return@get call.respond(HttpStatusCode.NotFound)

      call.respond(
        message = foundUser.toResponse()
      )
    }
  }

As we can see, from now on we can simply use the authorized whenever we want to limit endpoint accessibility to particular roles.

Of course, we must remember that this can be done only when the user is authenticated, because otherwise, we won’t be able to read the role claim.

Ktor RBAC summary

And that’s all for this article on how to implement role-based access control (RBAC) in Ktor.

I hope this article (and a series) helped you to learn how easily we can set up different things related to security in Ktor. If you haven’t seen previous materials yet, then you can do that right here:

As always, you can find a source code in this GitHub repository and let me know your thoughts in the comments section below 🙂

Share this:

Related content

3 Responses

  1. It is interesting. Ktor seems like a good library as an alternative to heavy-weight frameworks like Spring. We are using Javalin, but Ktor seems more powerful. In Javalin, there isn’t much pre-prepared integration/tooling. Everything must be written by my own. How are you satisfied with Ktor? Is it developed seriously or it has delays with delivery of new versions as Jetbrains does in its non-popular projects?

    1. Hey Hey Hey Andrew!

      Although I don’t have any experience with Javalin, Ktor seems to be better maintained and with a slightly bigger community.

      When we take a look at the releases https://github.com/ktorio/ktor/releases , we will see that it is maintained and even 3.0 version is about to come.

      If you are looking for something lightweight, something that is written with Kotlin in mind, something that does not bring tons of magic under the hood (like Spring Boot), thus requiring more knowledge of its internals, then Ktor can be a good choice.

  2. Hi Piotr,
    i really like your article. nice Job.
    i start learning Ktor through your article.
    But if you do not mind, i would like to ask if for these series articles, how can integrate the database section? Instead of saving the user in memory, i want to save them in Db. Same for the Token section.
    Do you have something which can help me continue on your series articles?
    Thanks.

Leave a Reply

Your email address will not be published. Required fields are marked *

Newsletter
Image presents 3 ebooks with Java, Spring and Kotlin interview questions.

Never miss any important updates from the Kotlin world and get 3 ebooks!

You may opt out any time. Terms of Use and Privacy Policy