因為 Ktor 本身只有實作 Authentication 機制,不像 Spring Security 有定義類似 UserDetails
, GrantedAuthority
的類別,也沒有 @Secured
, @RoleAllowed
…檢查使用者角色的方式,所以必須要自己設計實作 Role-Based Authorization 機制。
當 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()
我的想法是在 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 包含兩種子類別 Service
及 User
,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 及 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" })
}
以下面的程式碼為例,必須要滿足以下條件才可以呼叫建立使用者的 API
User
,而且角色為 Admin
的使用者routing {
authorize(ClubAuth.Admin) {
post("/user") {
// ...
}
}
}
val Admin = PrincipalAuth.User(
userAuthProviderName, App,
mapOf(ClubUserType.User.value to setOf(ClubUserRole.Admin.value))
)