Role-based authorization feature in Ktor (Web framework in Kotlin)

Role based authorization
Role authorization
In this tutorial, we will learn to create a custom Role-based authorization feature in Ktor. Till now it is not readily available in the Ktor framework. Recently Kotlin is gaining popularity and in effect to write fully asynchronous connected web server and client applications in Kotlin, Ktor web framework comes in the picture.
Using the Ktor framework, you can write your server or client application. In the application, there could be many APIs defined to serve different features. Given the nature of the features, few features need to be secured (with restricted use) and rest can be available to all. There are many ways to secure the API resources. One way is to allow access to only a limited category of users. For example, user management or application management related resources should be only accessible by Admin users. In the Jersey web framework, to secure such resources can be annotated with keyword RolesAllowed and pass the allowed user type values to it. But in Ktor it is not readily available, so on similar lines, we are going to develop our own custom RolesAllowed feature.
First, we will define the Roles to start with -
1 enum class Role(val roleStr: String) {
2   ADMIN("admin"),
3   USER("user")
4 }
Now we will create RoleAuthorization feature:
In Ktor, we need to provide custom configuration to feature which is our custom logic which authorizes the given role.
class RoleBasedAuthorizer {
  internal  var authorizationFunction: suspend ApplicationCall.(Set<Role>)->Either<String, Unit> = { Unit.right() }

  fun validate(body: suspend ApplicationCall.(Set<Role>)->Either<String, Unit>) {
    authorizationFunction = body
  }
}
Here RoleBasedAuthorizer is a provider class that has validate function which takes another function i.e. authorization function as arguments. Authorization function takes allowed roles as a parameter and applies given custom logic and returns Either: Unit on success and String on error.
Next, we need to configure the above provider for the feature. So below code is copying provider in the Configuration. Here RoleAuthorization is the main feature class that configures as per Ktor feature specs.
class RoleAuthorization internal constructor(config: Configuration) {

  val log = LoggerFactory.getLogger(RoleAuthorization::class.java)

  constructor(provider: RoleBasedAuthorizer): this(Configuration(provider))
  private var config = config.copy()

  class Configuration internal constructor(provider: RoleBasedAuthorizer) {
    var provider = provider

    internal fun copy(): Configuration = Configuration(provider)
  }  // class RoleBasedAuthorizer...  // fun interceptPipeline...  // Feature...}
Now, we will configure the actual feature:
companion object Feature : ApplicationFeature<ApplicationCallPipeline, RoleBasedAuthorizer, RoleAuthorization> {
  private val authorizationPhase = PipelinePhase("authorization")

  override val key: AttributeKey<RoleAuthorization> = AttributeKey("RoleAuthorization")

  @io.ktor.util.KtorExperimentalAPI
  override fun install(
      pipeline: ApplicationCallPipeline,
      configure: RoleBasedAuthorizer.() -> Unit
  ): RoleAuthorization {
    val configuration = RoleBasedAuthorizer().apply { configure }
    val feature = RoleAuthorization(configuration)

    return feature
  }
}
Here, we are defining the authorization phase. Basically, it identifies this feature in the Ktor application pipeline when gets installed. It takes the provider configuration and installs it in the pipeline and returns the installed feature’s instance.
As we have configured the feature, let’s define when to call it and what to do when it gets invoked.
fun interceptPipeline(pipeline: ApplicationCallPipeline, roles: Set<Role>) {
  pipeline.insertPhaseAfter(ApplicationCallPipeline.Features, authorizationPhase)
  pipeline.intercept(authorizationPhase) {
    val call = call
    config.provider.authorizationFunction(call, roles).fold(
        {
          log.debug("Responding unauthorized because of error", it)
          call.respond(HttpStatusCode.Forbidden,"Permission is denied")
          finish()
        },
        {
          return@intercept
        }
    )
  }}
Here, it defines when to call it that is what insertPhaseAfter doing here. It is installing our feature at the end of other features of the application pipeline. intercept function actually executes the RoleBasedAuthorizer provider function. Upon execution If it returns an error then it ends the call pipeline by calling finish() and responds with Forbidden HTTP error. otherwise continues with further application flow.
Till now we have created a complete feature and configured it.
rolesAllowed(Role.ADMIN) {
  route("/reports/usage/activity") {
    // some logic here when authorized...
  }
}
As shown above to restrict/secure the routes to be used only by ADMIN. We need to define extension function rolesAllowed on Route. This extension function take allowed roles and authorizes it. On success, it returns authorisedRoute and continues further execution.
fun Route.rolesAllowed(vararg roles: Role, build: Route.() -> Unit): Route {
  val authorisedRoute = createChild(AuthorisedRouteSelector())
  application.feature(RoleAuthorization).interceptPipeline(this.application, roles.toSet())

  authorisedRoute.build()
  return authorisedRoute
}
class AuthorisedRouteSelector(): RouteSelector(RouteSelectorEvaluation.qualityConstant) {
  override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation =
      RouteSelectorEvaluation.Constant
}
As of now we have defined complete feature and provided extension function to use on routes to restrict access. But it doesn’t make any sense till we actually install it in the Ktor’s application pipeline.
fun Application.mainModule(ac: ApplicationContext) {
  install(RoleAuthorization) {
    validate { allowedRoles ->
      // your custom logic for role authorition 
    }
  }
}
Here we are actually installing our feature in application pipeline and we can provide our validate function definition with custom logic. validate function makes available roles those we specify on Route.rolesAllowed extension function of Route for your custom role authorization logic.
As we reached the end of our tutorial, I hope it helps.

Comments

  1. Hey! Great work! Unfortunetaly I still don't get how should I validate roles?
    In block:
    install(RoleAuthorization) {
    validate { allowedRoles ->
    // your custom logic for role authorition
    }
    Here, should I compare roles from logged user with allowedRoles? How can I get loggedUser?

    ReplyDelete
    Replies
    1. @Zietek Thanks for the appreciation.

      Yes, compare the roles as you said.

      To answer the second question, to get loggedUser details, you required to install/apply ktor's authentication feature then you can easily retrieve an authentication context ie. loggedUser details by acessing property - "ApplicationCall.authentication"

      Like, In block:
      install(RoleAuthorization) {
      validate { allowedRoles ->
      // your custom logic for role authorition
      val loggedUser = authentication.principal
      }

      Delete
  2. Thank you so much for this useful information. looking more from your side to update us on more updates and advancements

    ReplyDelete
    Replies
    1. Thanks @Leo surely you will get more updates in near future.

      Delete
  3. Currently trying to implement this, but whenever I try to add a route behind a role is seems to start intercepting all Routes instead of the ones specified. Anybody got any ideas?

    ReplyDelete

Post a Comment