Search code examples
restkotlinwebapiktor

How do I implement authorization by roles in Ktor by methods


I have an API made with Ktor, I'm trying to implement authorization by roles as follows:

    internal class RoleBaseConfiguration(
        var requiredRoles: Set<String> = emptySet()
    )

    internal val globalconfig = RoleBaseConfiguration()

    internal val RoleAuthorizationPlugin = createApplicationPlugin(
        name = "RoleAuthorizationPlugin",
        createConfiguration = ::RoleBaseConfiguration
    ){
        pluginConfig.apply{
            on(AuthenticationChecked){ call ->
                val jwtToken = call.request.headers["Authorization"]?.toJWT() ?: throw 
                Exception("Missing principal")
                val roles = 
                JWTConfig.verifier.verify(jwtToken).getClaim("role")
                .toString().roleToSet()

                println("${globalconfig.requiredRoles} - $roles")

                if(roles.intersect(globalconfig.requiredRoles).isEmpty()){
                    call.respondText("You don`t have access to this resource.", status = 
                                    HttpStatusCode.Unauthorized)
                }
            }
        }
    }


    fun Route.withRole(role: String, build: Route.() -> Unit): Route {
        return withRoles(role, build = build)
    }

    fun Route.withRoles(vararg roles: String, build: Route.() -> Unit): Route {
        val authenticatedRoute = createChild(AuthorizationRouteSelector)

        /*authenticatedRoute.install(RoleAuthorizationPlugin) {
            this.requiredRoles = roles.toSet()
        }*/

        globalconfig.requiredRoles = roles.toSet()

        authenticatedRoute.build()
        return authenticatedRoute
    }

    object AuthorizationRouteSelector : RouteSelector() {
        override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): 
        RouteSelectorEvaluation {
            return RouteSelectorEvaluation.Transparent
        }

        override fun toString(): String = "(authorize \"default\" )"
    }
    
    fun String.roleToSet(): Set<String>{
        return split(",").map{ it.trim().replace("\"", "") }.toSet()
    }

    fun String.toJWT() = removePrefix("Bearer ")

Then in the routes file, I call the withRole and withRoles methods as follows:

 route("/auth") {
        authenticate {
            withRoles("user", "admin){
                get("register"){
                    //Implementar el registro
                }
            }
        }
            withRole("admin"){
                get("test"){
                    call.respond("Test")
                }
            }
    }

When I make a request to /auth/register with a user type user, I get 401 and println prints "[admin] - [user]" from what I understand that only the roles of the last call to withRole or withRoles are saved .

I've been researching, but I can't find the solution to make it work correctly. I thought about using createRouteScopePlugin(...) instead of createApplicationPlugin(...) but it turns out that if I use the first option, I can only call withRole or withRoles once and if I call it more than once throwing an exception saying that the plugin is already installed.


Solution

  • I see two problems in your solution. First, you use a global config that is shared among routes. Second, the AuthenticationChecked hook is executed only on a route level, but your plugin is application-scoped.

    I recommend storing required roles in the plugin's configuration and installing the plugin for each authorization scope.

    fun Route.withRoles(vararg roles: String, build: Route.() -> Unit) {
        val route = createChild(object : RouteSelector() {
            override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation {
                return RouteSelectorEvaluation.Transparent
            }
    
        })
        route.install(RoleAuthorizationPlugin) {
            roles(roles.toSet())
        }
    
        route.build()
    }
    
    
    class RoleBaseConfiguration {
        val requiredRoles = mutableSetOf<String>()
        fun roles(roles: Set<String>) {
            requiredRoles.addAll(roles)
        }
    }
    
    val RoleAuthorizationPlugin = createRouteScopedPlugin("RoleAuthorizationPlugin", ::RoleBaseConfiguration) {
        on(AuthenticationChecked) { call ->
            val principal = call.principal<JWTPrincipal>() ?: return@on
            val roles = principal.payload.getClaim("role").asList(String::class.java).toSet()
    
            if (pluginConfig.requiredRoles.isNotEmpty() && roles.intersect(pluginConfig.requiredRoles).isEmpty()) {
                call.respondText("You don`t have access to this resource.", status = HttpStatusCode.Unauthorized)
            }
        }
    }
    

    Here is an example of the plugin usage in the routing:

    routing {
        authenticate("auth-jwt") {
            withRoles("admin") {
                get("/admin") {
                    call.respondText { "For admin only" }
                }
            }
    
            withRoles("registered") {
                get("/reg") {
                    call.respondText { "For registered users" }
                }
            }
    
            withRoles {
                get("/anyone") {
                    call.respondText { "For anyone" }
                }
            }
        }
    }