[Day 14] 实作 API Role-Based Authorization

因为 Ktor 本身只有实作 Authentication 机制,不像 Spring Security 有定义类似 UserDetails, GrantedAuthority 的类别,也没有 @Secured, @RoleAllowed …检查使用者角色的方式,所以必须要自己设计实作 Role-Based Authorization 机制。

定义基础类别 UserType, UserRole and UserPrincipal

当 Ktor 验证请求成功後,就可以拿到 UserPrincipal 物件,里面包含使用者的 UserType 及 UserRole 资料,然後我们就可以继续验证使用者角色权限。

open class UserType(val projectId: String, val name: String) {

    override val id: String = "${projectId}_${name}"
    open val roles: Set<UserRole>? = null
}

class UserRole(
    private val userTypeId: String,
    override val name: String,
    @Transient private val parent: UserRole? = null
) {
    override val id: String = "${userTypeId}_$name"
}

class UserPrincipal(
    val userType: UserType,
    val userId: UUID,
    val roles: Set<UserRole>? = null,
    override val source: PrincipalSource,
    var session: UserSession? = null
) : MyPrincipal()

实作 authorization function 保护 API

我的想法是在 Ktor Authentication 的基础上扩展实作 Authoriation。以下面的例子而言,Ktor 是使用 authenticate function 指定 provider name auth-basic,建立 scope 保护里面所有巢状阶层的 route。同样的作法,我想实作 authorization function 指定允许的角色 admin,建立 scope 保护里面所有巢状阶层的 routes。

// ktor authentication function
routing {
    authenticate("auth-basic") {
        get("/login") {
            // ...
        }
    }
}

// 自己实作 authorization function 取代 ktor authentication function
routing {
    authorize(ClubAuth.Admin) {
        post("/user") {
            // ...
        }
    }
}

authorization function 是 Route 的 extension function,可接受多个不定长参数的 PrincipalAuth 物件,PrincipalAuth 是我们事先定义好的物件,含有 authentication provider name 及 user role 资讯,然後我们在 Authentication Pipeline 加上指定 provider name 保护的 authenticatedRoute,这部分的实作是使用既有 Ktor Authentication Plugin 的机制,所以剩下检查 user role-based authorization 的部分需要自行设计实作。

验证顺序上是先做 authentication 再 authorization,所以我们在 Authentication.ChallengePhase 阶段已经可以从 Principal 取得使用者角色,接下来呼叫每一个 PrincipalAuth 的 allow() function,如果没有任何一个 PrincipalAuth 检查通过,就丢出例外回传 AUTH_ROLE_FORBIDDEN 回应码。

fun Route.authorize(
    vararg principalAuths: PrincipalAuth,
    build: Route.() -> Unit
): Route {
    val configurationNames = principalAuths.map { it.id }.toMutableList()
    val authenticatedRoute = createChild(AuthorizationRouteSelector(configurationNames, principalAuths.toList()))
    application.feature(Authentication).interceptPipeline(authenticatedRoute, configurationNames, false)
    authenticatedRoute.intercept(Authentication.ChallengePhase) {
        val principal = call.authentication.principal

        if (principal == null) {
            if (call.response.status() != null) finish()
            else error("principal is null and no response in authorize challenge phase")
        } else {
            if (principalAuths.none { it.allow(principal, call) }) {
                throw RequestException(InfraResponseCode.AUTH_ROLE_FORBIDDEN, "$principal is forbidden unable to access this api")
            } else {
                logger.debug("$principal authenticated")
            }
        }
    }
    authenticatedRoute.build()
    return authenticatedRoute
}

PrincipalAuth 验证呼叫端来源及使用者角色

PrincipalAuth 包含两种子类别 ServiceUser,Service 使用於只需要验证呼叫端来源的情况,效果等於 authentication,至於 User 就要再多检查使用者角色。

我设计一个使用者 User 是属於某一种 UserType,而且可以拥有该 UserType 的多个 UserRole,例如後台人员有 admin, employee 角色,同时前台使用者有 admin, member 角色。只要 UserPrincipal 的 roles 符合任何一个 PrincipalAuth 的 roles,那麽 allow() 就会回传 true。

sealed class PrincipalAuth(
    override val id: String,
    val allowSources: Set<PrincipalSource>
) {

    abstract fun allow(principal: MyPrincipal, call: ApplicationCall): Boolean
    
        class Service(
        providerName: String,
        allowSources: Set<PrincipalSource>,
        private val allowPredicate: ((ServicePrincipal, ApplicationCall) -> Boolean)? = null
    ) : PrincipalAuth(providerName, allowSources) {

        override fun allow(principal: MyPrincipal, call: ApplicationCall): Boolean {
            return if (principal is ServicePrincipal) {
                if (!allowSources.contains(principal.source)) return false
                allowPredicate?.invoke(principal, call) ?: true
            } else false
        }
    }

    class User(
        providerName: String,
        allowSources: Set<PrincipalSource>,
        val typeRolesMap: Map<UserType, Set<UserRole>?>,
        private val allowPredicate: ((UserPrincipal) -> Boolean)? = null
    ) : PrincipalAuth(providerName, allowSources) {

        override fun allow(principal: MyPrincipal, call: ApplicationCall): Boolean {
            return if (principal is UserPrincipal) {
                if (!allowSources.contains(principal.source)) return false
                if (!typeRolesMap.containsKey(principal.userType)) return false
                val roles = typeRolesMap[principal.userType]
                if (roles.isNullOrEmpty()) return true
                if (principal.roles.isNullOrEmpty() || principal.roles.none { it in roles }) return false
                allowPredicate?.invoke(principal) ?: true
            } else false
        }
    }
}

子专案定义自己的 UserType and UserRole

UserType 及 UserRole 使用 enum class 实作而非字串,之後在 authorization function 指定角色时,才不会因为打错字而出错。

enum class ClubUserType(val value: UserType) {

    User(object : UserType(ClubConst.projectId, "user") {
        override val roles: Set<UserRole> = setOf(UserRole(id, "admin"), UserRole(id, "member"))
    }
}

enum class ClubUserRole(val value: UserRole) {

    Admin(ClubUserType.User.value.roles!!.first { it.name == "admin" }),
    Member(ClubUserType.User.value.roles!!.first { it.name == "member" })
}

子专案定义 PrincipalAuth 套用於 API

以下面的程序码为例,必须要满足以下条件才可以呼叫建立使用者的 API

  • 来自 App 端的请求
  • UserType 为 User,而且角色为 Admin 的使用者
routing {
    authorize(ClubAuth.Admin) {
        post("/user") {
            // ...
        }
    }
}

val Admin = PrincipalAuth.User(
    userAuthProviderName, App,
    mapOf(ClubUserType.User.value to setOf(ClubUserRole.Admin.value))
)

<<:  [Day18] Esp32用STA mode + Relay - (程序码讲解)

>>:  Day05: 05 - Django架构规划、资料库规划、商品资料准备

Day 06:3 Sum

在Two Sum 中 我们一开始最初的想法是用2次的loop检查,那换做这3 Sum我们当然可以用三...

Day 0x1 Intro & UVa10055 Hashmat the Brave Warrior

Intro UVa 一颗星选集 UVa Online Judge (wiki) 为线上自动评断系统,...

[Android Studio 30天自我挑战] Toast浮动显示快显元件

Toast元件可以短暂的在画面跳出提示讯息,并且不会影响Activity处理程序,当达到短暂秒数後便...

Day 06 - FOREIGN KEY 实际把键连起来!

上一篇我们大致介绍了FOREIGN KEY的作用,那我们现在直接在MYSQL上操作给大家看喽! 首先...

Day 18:Kotlin 过滤(filter)集合资料用法

本篇文章同步发表在 HKT 线上教室 部落格,线上影音教学课程已上架至 Udemy 和 Youtu...