iT邦幫忙

2021 iThome 鐵人賽

DAY 14
0
Modern Web

基於 Kotlin Ktor 建構支援模組化開發的 Web 框架系列 第 14

[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))
)

上一篇
[Day 13] 實作 API Authentication
下一篇
[Day 15] 實作 OpenAPI Plugin 產生 API 文件
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言