3K整合系列(三) Ktor 角色权限控制

3K = Kotlin + Ktor + Ktorm,不要记错了哦

在上一篇里,我们成功整合了 Ktor 和 Ktorm,并完成了一个简单的用户登录登出。在现实情况里,用户的权限控制一直是非常重要的事情,对于没有权限的用户,某些接口就不能放行。

在 Ktor 里面,我们可以通过一种很简单的方式来实现它,比如说:

get("/sample") {
    val u = user
    val perms = UserMapper.getPermissions(u.userId)
    if (!perms.contains("perm:user:info")) {
        call.respond(AjaxResult.error("你没有权限访问这个接口"))
        return@get
    }
    ... ...
}

是的,这是一般的实现方法,但是对于大部分接口都要写这些代码,就非常的不友好了,代码量太多,也不好控制,而且重复的代码非常让人厌烦。所以在这里,我们需要用 AOP 的方法去解决,按以往对 Ktor 插件的了解,在这个场景下,我们也应该通过编写插件来解决问题,那么下面就正式开始吧。


首先我们要知道,Ktor 官方是有一套插件的标准格式的,只有这样写,Ktor 才会承认它是一个合法的插件:

class RoleBasedAuthorization(internal var config: RoleAuthorizationConfig) {

    fun configure(block: RoleAuthorizationConfig.() -> Unit) {
        val newConfig = config.copy()
        block(newConfig)
        config = newConfig.copy()
    }

    companion object : BaseApplicationPlugin<Application, RoleAuthorizationConfig, RoleBasedAuthorization> {
        override val key: AttributeKey<RoleBasedAuthorization> = AttributeKey("RoleAuthorizationHolder")

        override fun install(pipeline: Application, configure: RoleAuthorizationConfig.() -> Unit): RoleBasedAuthorization {
            val cfg = RoleAuthorizationConfig().apply(configure)
            return RoleBasedAuthorization(cfg)
        }
    }
}

由于我们需要在插件里面能够动态获取用户的实际权限,因此在 Config 里面,就要把获取权限的函数,以及权限校验失败的函数予以写出:

class RoleAuthorizationConfig(
    var internalGetRoles: (Principal) -> Set<String> = { emptySet() },
    var internalRoleAuthFailed: suspend ApplicationCall.(message: String) -> Unit = {}) {

    fun getRoles(block: (Principal) -> Set<String>) {
        internalGetRoles = block
    }

    fun roleAuthFailed(block: suspend ApplicationCall.(message: String) -> Unit) {
        internalRoleAuthFailed = block
    }

    internal fun copy(): RoleAuthorizationConfig = RoleAuthorizationConfig(internalGetRoles, internalRoleAuthFailed)
}

首次看到这种写法的同学请不要惊讶,这只是 Kotlin 强大的语言特性之一,你可以把匿名函数作为类型来使用。并且,如果你觉得每次都要写函数定义很麻烦,也可以将函数定义成类型:

typealias GetRoleFunc = (Principal) -> Set<String>

好了,到这里配置的部分已经完成了,我们可以向 Ktor 注册这个插件:

install(RoleBasedAuthorization) {
    getRole {
        it as SessionUser
        UserMapper.getPermissions(it.userId)
    }
    roleAuthFailed {
        respond(AjaxResult.error(it))
    }
}

是不是很简单,直接在插件里面就可以定义用于获取权限的方法,以及在权限验证失败后如何返回。下面我们就要将这个权限验证挂到某个路由上,也就是在指定的路由上 AOP 这个权限验证。

为了直观起见,我们先定义好 AOP 的方式吧,就以上面的 get 方法来说,加入权限验证后,写法变成这样:

withRoles("perm:user:info") {
    get("/sample") {
        ... ...
    }
}

这样看起来就一目了然了,不会对 get 内部的代码造成侵入。所以现在要写的,就是这个 withRoles 方法了。同样的,我们先写好架子:

fun Route.withRoles(vararg roles: String, build: Route.() -> Unit): Route {
    val authorizedRoute = createChild(RoleAuthorizationRouteSelector(roles.joinToString(",")))
    authorizedRoute.install(RoleAuthenticationInterceptors) {
        this.roles = roles.toSet()
    }
    authorizedRoute.build()
    return authorizedRoute
}

这些代码也很好理解,为当前的路由创建一个子路由,然后向该子路由安装一个插件,并给定插件的参数,最后构建并返回该子路由。这里可以很清晰的看到 AOP 的过程了,不同于以往我们用注解写 AOP,Ktor 的插件可是实打实的代码,每一步都让你看得清清楚楚。

关于这个 RoleAuthorizationRouteSelector,它是一个路由的选择器,有几种可选的模式,请参考表格:

选择器模式 含义
Failed 路由未找到时可选
FailedPath 路由未找到时可选(与Failed相同)
FailedMethod 请求方式不允许时可选
FailedParameter 请求参数错误时可选
Missing 有可选参数未填时可选
Constant 有静态值被传入时可选
Transparent 不会改变原有路由的入参情况时可选(通常是选这个)
ConstantPath 针对单个路由传入静态值时可选
WildcardPath 针对通配路由时可选

在这个表的基础上,我们可以很轻松的写出一个路由选择器:

private class RoleAuthorizationRouteSelector(private val description: String) : RouteSelector() {
    override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation =
        RouteSelectorEvaluation.Transparent

    override fun toString(): String = "(authorize ${description})"
}

然后,RoleAuthenticationInterceptors 才是真正处理 AOP 动作的插件,从它的命名上也可以看出来,这是个拦截器,把送往路由的请求先进行拦截,经过一系列操作后,再予以放行或者不放行。拦截器的代码也是基本上固定的套路,如下所示:

private val RoleAuthenticationInterceptors: RouteScopedPlugin<RouteRoleAuthorizationConfig> =
    createRouteScopedPlugin("RoleAuthenticationInterceptors", ::RouteRoleAuthorizationConfig) {
        on(AuthenticationChecked) { call ->
            val reqRoles = pluginConfig.roles
            val authConfig = call.application.plugin(RoleBasedAuthorization).config
            val reqGetRoles = authConfig.internalGetRoles
            val reqOnFail = authConfig.internalRoleAuthFailed
            val user = call.principal<Principal>()
            if (user == null) {
                reqOnFail(call, "Unauthenticated User")
                return@on
            }
            val roles = reqGetRoles(user)
            val missing = reqRoles - roles
            if (missing.isNotEmpty()) {
                reqOnFail(call, "Principal $user lacks required role(s) ${missing.joinToString(" and ")}")
            }
        }
    }

Ktor 提供了两种获取配置的方式,一种是通过 pluginConfig,这种方式可以获得在路由上动态挂载的配置内容,另一种是通过 call.application.plugin 来获取,这种方式可以获得通过静态的 install 安装的插件里所配置的内容。

好了,基本上也算是写完了,那就简单的测试一下吧:

$ curl http://0.0.0.0:8080/sample -b cookie1
{
    "code": 500,
    "message": "请求失败",
    "data": "Principal (userId=1, userName=admin) lacks required role(s) perm:user:info"
}

$ curl http://0.0.0.0:8080/sample -b cookie2
{
    "code": 200,
    "message": "请求成功",
    "data": {
        "userId": 1,
        "userName": "admin"
    }
}

似乎到了这里就已经完成了对用户权限的控制,但是我们是要精益求精的,比如说这段代码,你看着没觉得不舒服么:

install(RoleBasedAuthorization) {
    getRole {
        it as SessionUser
        UserMapper.getPermissions(it.userId)
    }
}

为什么还要 it as SessionUser 呢,为什么不是直接输出一个 SessionUser 类型的对象?

可能你会说,看了上面的代码,似乎也没有哪个地方可以让我塞进一个泛型呀。比如说想实现以下代码是不能的:

class RoleAuthorizationConfig<T: Principal>(
    var internalGetRoles: (T) -> Set<String> = { emptySet() },
    fun getRoles(block: (T) -> Set<String>) {
        internalGetRoles = block
    }
    internal fun copy(): RoleAuthorizationConfig = RoleAuthorizationConfig(internalGetRoles, internalRoleAuthFailed)
}

这个代码看起来是可行的,但是却没有办法塞在 RoleBasedAuthorization 里,这意味着要让 RoleBasedAuthorization 也带上泛型,然而 Ktor 对于插件的要求又是不允许带有泛型,这可如何是好?

答案是,在 Kotlin 里面是可以变魔术的,插件本体不允许带泛型,可没说插件里面的各个东西不能带吧,正是在这个指导思想下,我们可以实现大魔术。以下的代码是完整的角色权限插件,大家也可以亲自体会一下这个魔术的原理:

enum class RoleAuthorizationType { ALL, ANY, NONE }

class RoleBasedAuthorization(internal var config: RoleBasedAuthorizationConfig) {
    fun configure(block: RoleBasedAuthorizationConfig.() -> Unit) {
        val newConfiguration = config.copy()
        block(newConfiguration)
        config = newConfiguration
    }

    companion object : BaseApplicationPlugin<Application, RoleBasedAuthorizationConfig, RoleBasedAuthorization> {
        override val key: AttributeKey<RoleBasedAuthorization> = AttributeKey("RoleBasedAuthorizationHolder")

        override fun install(pipeline: Application, configure: RoleBasedAuthorizationConfig.() -> Unit): RoleBasedAuthorization {
            val config = RoleBasedAuthorizationConfig().apply(configure)
            return RoleBasedAuthorization(config)
        }
    }
}

class RoleBasedAuthorizationConfig(
        var type: RoleAuthorizationType = RoleAuthorizationType.ANY,
        var roles: Set<String> = emptySet(),
        var provider: BaseRoleBasedAuthorizationProvider? = null) {
    internal fun copy(): RoleBasedAuthorizationConfig = RoleBasedAuthorizationConfig(type, roles, provider)
}

val RoleAuthenticationInterceptors: RouteScopedPlugin<RoleBasedAuthorizationConfig> =
        createRouteScopedPlugin("RoleAuthenticationInterceptors", ::RoleBasedAuthorizationConfig) {
            // 这种方式获取真实使用时的配置内容
            val reqRoles = pluginConfig.roles
            val reqType = pluginConfig.type
            // 这种方式获取 install 时注册的配置内容
            val config = application.plugin(RoleBasedAuthorization).config
            on(AuthenticationChecked) { call ->
                if (call.isHandled) {
                    return@on
                }
                config.provider?.onRoleAuthorization(call, reqType, reqRoles)
            }
        }

private class RoleAuthorizationRouteSelector(private val description: Set<String>) : RouteSelector() {
    override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation =
            RouteSelectorEvaluation.Transparent

    override fun toString(): String = "(authorize ${description.joinToString(",")})"
}

private fun Route.withRoles(roles: Set<String>, roleAuthType: RoleAuthorizationType, build: Route.() -> Unit): Route {
    require(roles.isNotEmpty()) { "At least one role name need to be provided" }
    val roleAuthRoute = createChild(RoleAuthorizationRouteSelector(roles))
    roleAuthRoute.install(RoleAuthenticationInterceptors) {
        this.roles = roles
        this.type = roleAuthType
    }
    roleAuthRoute.build()
    return roleAuthRoute
}


abstract class BaseRoleBasedAuthorizationProvider {
    abstract suspend fun onRoleAuthorization(call: ApplicationCall, reqType: RoleAuthorizationType, reqRoles: Set<String>)
    open class Config protected constructor()
}

typealias RoleBasedAuthorizationGetRoleFunc<T> = suspend ApplicationCall.(T) -> Set<String>
typealias RoleBasedAuthorizationRoleAuthFailedFunc = suspend ApplicationCall.(String) -> Unit

class RoleBasedAuthorizationProvider<T : Any>(config: Config<T>) : BaseRoleBasedAuthorizationProvider() {

    private val type: KClass<T> = config.type
    private val getRoleFunc: RoleBasedAuthorizationGetRoleFunc<T> = config.getRoleFunc
    private val roleAuthFailedFunc: RoleBasedAuthorizationRoleAuthFailedFunc = config.roleAuthFailedFunc

    override suspend fun onRoleAuthorization(call: ApplicationCall, reqType: RoleAuthorizationType, reqRoles: Set<String>) {
        val user = call.sessions.get(type)
        if (user == null) {
            roleAuthFailedFunc(call, "Unauthenticated User")
            return
        }
        val roles = getRoleFunc(call, user)
        val denyReasons = mutableListOf<String>()
        when (reqType) {
            RoleAuthorizationType.ALL -> {
                val missing = reqRoles - roles
                if (missing.isNotEmpty()) {
                    denyReasons += "Principal $user lacks required role(s) ${missing.joinToString(" and ")}"
                }
            }

            RoleAuthorizationType.ANY -> {
                if (roles.none { it in reqRoles }) {
                    denyReasons += "Principal $user has none of the sufficient role(s) ${reqRoles.joinToString(" or ")}"
                }
            }

            RoleAuthorizationType.NONE -> {
                if (roles.any { it in reqRoles }) {
                    denyReasons += "Principal $user has forbidden role(s) ${reqRoles.intersect(roles).joinToString(" and ")}"
                }
            }
        }
        if (denyReasons.isNotEmpty()) {
            val message = denyReasons.joinToString(". ")
            roleAuthFailedFunc(call, message)
        }
    }

    class Config<T : Any>(internal val type: KClass<T>) : BaseRoleBasedAuthorizationProvider.Config() {
        internal var getRoleFunc: RoleBasedAuthorizationGetRoleFunc<T> = { emptySet() }
        internal var roleAuthFailedFunc: RoleBasedAuthorizationRoleAuthFailedFunc = {}
        fun getRole(block: RoleBasedAuthorizationGetRoleFunc<T>) {
            getRoleFunc = block
        }

        fun roleAuthFailed(block: RoleBasedAuthorizationRoleAuthFailedFunc) {
            roleAuthFailedFunc = block
        }

        fun buildProvider(): RoleBasedAuthorizationProvider<T> = RoleBasedAuthorizationProvider(this)
    }

}

inline fun <reified T : Any> RoleBasedAuthorizationConfig.roleSession(configure: RoleBasedAuthorizationProvider.Config<T>.() -> Unit) {
    val provider = RoleBasedAuthorizationProvider.Config(T::class).apply(configure).buildProvider()
    this.provider = provider
}

fun Route.withRoles(vararg roles: String, build: Route.() -> Unit) =
        withRoles(roles.toSet(), RoleAuthorizationType.ALL, build)

fun Route.withAnyRole(vararg roles: String, build: Route.() -> Unit) =
        withRoles(roles.toSet(), RoleAuthorizationType.ANY, build)

fun Route.withoutRoles(vararg roles: String, build: Route.() -> Unit) =
        withRoles(roles.toSet(), RoleAuthorizationType.NONE, build)

这里通过一个不带泛型的 Base Provider 和一个带泛型的实体 Provider 来实现了将泛型带进插件里。于是我们可以通过这样的代码来写:

install(RoleBasedAuthorization) {
    roleSession<SessionUser> {
        getRole {
            UserMapper.getPermissions(it.userId)
        }
    }
}

为了进一步方便起见,可以封装一个函数:

inline fun <reified T : Principal> Application.pluginRoleAuthorization(
        crossinline configure: RoleBasedAuthorizationProvider.Config<T>.() -> Unit
) = install(RoleBasedAuthorization) {
    roleSession<T>(configure)
}

这样我们就可以用一种非常轻松的方法来使用插件了:

pluginRoleAuthorization<SessionUser> {
    getRole {
        UserMapper.getInfo(it.userId)?.perms ?: setOf()
    }
    roleAuthFailed {
        respond(AjaxResult.error("您没有权限访问这个接口"))
    }
}

好了,用户角色权限控制到这里就结束了,愉快的使用封装好的代码吧,如果后续还需要其他的 AOP,也可以通过同样的方式来实现插件,熟悉 Ktor 的插件机制,对于灵活使用这个框架有着相当大的好处。

下一篇将讲述如何在 Ktor 内使用 JNI,从而实现与原生层交互,同时也将讲述如何使用 Kotlin 本身的代码来编写一个标准的 JNI 库。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345