iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Software Development

Kotlin 手刻 Ktor 從零開始系列 第 23

Kotlin 手刻 Ktor 從零開始 Day 23 Authentication / Authorization,認證與授權

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260822/20121948NIqCtgaIUJ.png

第 22 篇解決「輸入是否合理」,這篇處理另一個問題,「誰可以做什麼」

  • Authentication (認證),你是誰
  • Authorization (授權),你能做什麼

這兩件事常常被放在一起講,但它們在框架裡是兩個不同的位置,認證是把 token 換成一個身份,授權是拿這個身份去比對路由要求的權限,分開做,兩邊都會變得比較好測

目標 API

做完之後,路由層看起來會是這樣

app.routing {
    get("/public") { ok("hello") }

    authenticate {
        get("/me") { ok(principal<UserPrincipal>().name) }
    }

    authorize("admin") {
        get("/admin") { ok("admin panel") }
    }
}

三種路由各自對應一種結果,/public 誰都能打,/me 要有效 token,/admin 除了 token 還要 admin 這個角色。handler 裡不用寫任何 if,權限檢查在進 handler 之前就結束了

認證與授權分成四個部分

跟第 22 篇的驗證一樣,這裡也是由內往外疊,每個部分都能單獨測

  • parseBearer() 只認得字串,把 Authorization: Bearer abc123 拆出 abc123,不碰 HTTP 也不碰 principal
  • Principalprincipal<T>() 負責身份在 CallContext 裡的存取,只要一個 RelixCall 就測得到
  • Authentication Plugin 加上 authenticate { },把前面兩個部分接到 pipeline 上,這個部分開始要起 app
  • authorize { } 的角色檢查疊在認證之上,決定 403 什麼時候出現

程式碼會新增一個檔案 Auth.kt,放 parseBearerPrincipalPrincipalKeyAuthenticationConfigAuthentication 跟 auth middleware

既有的檔案改三個地方,Route 多兩個欄位,第 10 篇建立的 RoutingBuilder.ktauthenticateauthorize 兩個函式,RelixCall.kt 多一個 principal<T>()

Route 加欄位會連帶影響到既有的呼叫點,RouteGroup.ktRoutingBuilder.ktRelixApplication.kt 這三個檔案裡建 Route 的地方要換成具名參數,RouterTest.ktPathParamTest.kt 也有少數幾個測試要跟著改,理由在第三個部分說明

測試四個部分各一個檔案,BearerParserTest.ktPrincipalTest.ktAuthenticationTest.ktAuthorizationTest.kt

TDD 先確認 parseBearer 的行為

最裡面這個部分就是字串進、字串出,token 有效不有效它不管,那是下一個部分的事,不需要 app、不需要 request,直接呼叫,測試檔案放 BearerParserTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class BearerParserTest {

    @Test
    fun `parses a normal bearer header`() {
        assertEquals("abc123", parseBearer("Bearer abc123"))
    }

    @Test
    fun `scheme is case insensitive`() {
        assertEquals("abc123", parseBearer("bearer abc123"))
    }

    @Test
    fun `trims surrounding whitespace from the token`() {
        assertEquals("abc123", parseBearer("Bearer   abc123  "))
    }

    @Test
    fun `returns null when the header is absent`() {
        assertNull(parseBearer(null))
    }

    @Test
    fun `returns null for a different scheme`() {
        assertNull(parseBearer("Basic dXNlcjpwYXNz"))
    }

    @Test
    fun `returns null when the token is empty`() {
        assertNull(parseBearer("Bearer "))
    }

    @Test
    fun `returns null when the token is whitespace only`() {
        assertNull(parseBearer("Bearer    "))
    }
}

七個測試,一個成功四個 null,回 null 的四種情況要分開測,因為它們的成因不一樣,沒有 header、換了別的 scheme、token 是空的、token 只有空白,任何一種漏掉,後面 middleware 就會拿到一個看起來像 token 的空字串

大小寫那個測試是照著 RFC 7235 Section 2.1 寫的,auth scheme 規定是 case-insensitive,有些 client library 送出來的是 bearer

實作 parseBearer

新增 Auth.kt

fun parseBearer(header: String?): String? {
    if (header == null) {
        return null
    }
    val prefix = "Bearer "
    if (!header.startsWith(prefix, ignoreCase = true)) {
        return null
    }
    val token = header.substring(prefix.length).trim()
    return token.ifEmpty { null }
}

trim() 之後接 ifEmpty { null },把「只有空白」跟「完全沒有」變成同一個結果,呼叫端只要判斷 null 就好,不用再多想一種空字串的狀況。七個測試到這裡全部通過

TDD 先確認 principal 取得身份的方式

第二個部分要回答的是,身份放進 CallContext 之後怎麼拿回來,以及拿不到的時候會發生什麼事

這個部分還是不用起 app,RelixCall 建出來就能用,測試檔案放 PrincipalTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

data class ServicePrincipal(val clientId: String) : Principal

class PrincipalTest {

    private fun emptyCall(): RelixCall {
        val app = RelixApplication()
        val request = RelixRequest(
            method = "GET",
            path = "/me",
            headers = emptyMap(),
            queryParameters = emptyMap(),
        )
        return RelixCall(app, request)
    }

    @Test
    fun `principal returns the value put into the context`() {
        val call = emptyCall()
        call.context[PrincipalKey] = UserPrincipal("1", "Alice")

        val user = call.principal<UserPrincipal>()

        assertEquals("1", user.userId)
        assertEquals("Alice", user.name)
    }

    @Test
    fun `principal throws 401 when the context is empty`() {
        val call = emptyCall()

        val e = assertFailsWith<RelixHttpException> { call.principal<UserPrincipal>() }

        assertEquals(401, e.statusCode)
    }

    @Test
    fun `principal throws 500 when the type does not match`() {
        val call = emptyCall()
        call.context[PrincipalKey] = ServicePrincipal("svc-1")

        val e = assertFailsWith<RelixHttpException> { call.principal<UserPrincipal>() }

        assertEquals(500, e.statusCode)
    }
}

UserPrincipal 是應用層的身份型別,ServicePrincipal 則只有第三個測試用得到,宣告在測試檔裡就好

emptyCall()headersqueryParameters 明確填成 emptyMap(),第 4 篇的 RelixRequest 這兩個欄位沒有預設值,只有 body 有。這個部分根本不看 header,補這兩個空 map 純粹是為了讓建構子過得去,三個測試共用同一個 helper 就不用各寫一次

401 跟 500 這兩個測試分開的理由是成因不同,context 是空的表示認證沒發生過,這是 client 的問題,型別對不上表示 middleware 塞進去的東西跟 handler 要的不是同一種,這是框架或設定的問題,不該讓 client 看到 401 然後以為換個 token 就會好

實作 Principal 與 principal()

Principal 只有一行,是個 marker interface,一樣放進 Auth.kt

interface Principal

具體的身份由應用層自己定義

data class UserPrincipal(
    val userId: String,
    val name: String,
    val roles: Set<String> = emptySet(),
) : Principal

為什麼不在框架裡定義 UserPrincipal ? 因為每個應用的使用者模型不一樣,有些只需要 userId,有些需要角色清單、權限列表、組織 ID,框架提供介面,應用填入內容,roles 這個欄位到第四個部分才會用到,先放在這裡

接著是 typed key 一樣放在 Auth.kt 裡面

val PrincipalKey = CallContext.Key<Principal>("principal")

取值函式則放在 RelixCall.kt 裡面

class RelixCall(
    val application: RelixApplication,
    val request: RelixRequest,
    internal var pathParams: Map<String, String> = emptyMap(),
    internal var matchedRoute: Route? = null,
) {
    inline fun <reified T : Principal> principal(): T {
        val p = context[PrincipalKey]
            ?: throw RelixHttpException(401, "Not authenticated")
        return p as? T
            ?: throw RelixHttpException(500, "Principal type mismatch: expected ${T::class.simpleName}")
    }

    // ...
}

PrincipalKey 是第 12 篇介紹的 CallContext.Key<T>,它保證 key 名稱不會拼錯,也保證拿回來的一定是 Principal,但保證不到那是你要的哪一種 Principal,把 T 檢查出來的是簽名上的 reified,compiler 會把實際型別 inline 到呼叫點,p as? T 才是真的 runtime 檢查,型別不符才回得了 500

TDD 確認認證這條路徑

前面兩個部分都沒碰到 HTTP,plugin 還沒裝、middleware 也還沒寫,這輪要測的正是這段接線,plugin 有裝、路由標記得到、401 有沒有真的出去,所以回到 TestKit,測試檔案放 AuthenticationTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class AuthenticationTest {

    private fun createApp(): RelixApplication {
        val app = RelixApplication()
        app.install(ErrorHandling)
        app.install(Authentication) {
            bearer { token ->
                when (token) {
                    "token-alice" -> UserPrincipal("1", "Alice")
                    else -> null
                }
            }
        }
        app.routing {
            get("/public") { ok("hello") }

            authenticate {
                get("/me") { ok(principal<UserPrincipal>().name) }
            }
        }
        return app
    }

    @Test
    fun `public route needs no token`() {
        val response = RelixTestKit(createApp()).handleRequest("GET", "/public")

        assertEquals(200, response.statusCode)
        assertEquals("hello", response.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `protected route without a token returns 401`() {
        val response = RelixTestKit(createApp()).handleRequest("GET", "/me")

        assertEquals(401, response.statusCode)
        assertTrue(response.headers["WWW-Authenticate"]?.contains("Bearer") == true)
    }

    @Test
    fun `protected route with an invalid token returns 401`() {
        val response = RelixTestKit(createApp()).handleRequest(
            method = "GET",
            path = "/me",
            headers = mapOf("Authorization" to listOf("Bearer bad-token")),
        )

        assertEquals(401, response.statusCode)
    }

    @Test
    fun `protected route with a valid token sees the principal`() {
        val response = RelixTestKit(createApp()).handleRequest(
            method = "GET",
            path = "/me",
            headers = mapOf("Authorization" to listOf("Bearer token-alice")),
        )

        assertEquals(200, response.statusCode)
        assertEquals("Alice", response.body.toString(Charsets.UTF_8))
    }
}

createApp() 裡那行 install(ErrorHandling) 跟第 21、22 篇是同一個理由,principal<T>()throw 表達錯誤,丟的是第 16 篇那個 RelixHttpException,沒有 middleware 在外面接住,exception 會一路穿出去,測試拿到的不是 500 而是直接失敗

沒帶 token 那個測試多斷言了一個 WWW-Authenticate header,401 只寫 status code 是不夠的,RFC 7235 規定 401 必須告訴 client 該用哪種方式認證,少了這個 header,client 只知道被拒絕,不知道下一步要做什麼

實作 Authentication Plugin 與 authenticate

先看 config 跟 plugin 本體,一樣放到 Auth.kt

class AuthenticationConfig {
    internal var bearerValidator: ((String) -> Principal?)? = null

    fun bearer(validate: (String) -> Principal?) {
        bearerValidator = validate
    }
}

object Authentication : RelixPlugin<AuthenticationConfig> {
    override fun createDefaultConfig() = AuthenticationConfig()

    override fun install(
        application: RelixApplication,
        config: AuthenticationConfig,
    ) {
        application.use(authMiddleware(config))
    }
}

Authentication 是一個 RelixPlugin<AuthenticationConfig>,跟第 18 篇的 Plugin 系統接起來,驗證函式的簽名是 (String) -> Principal?,給 token 字串,回 Principal 表示成功,回 null 表示失敗,不丟 exception,null 就夠了

路由這邊要有辦法標記「這條需要認證」,Route 加一個旗標

data class Route(
    val method: String,
    val path: String,
    val requiresAuth: Boolean = false,
    val handler: RelixHandler,
)

新欄位插在 handler 前面,這個位置不能隨便放,第 8、9 篇的 RouterTestPathParamTest 是這樣建 route 的

val route = Route("GET", "/users/{id}") { ok("user") }

trailing lambda 語法綁的是最後一個參數handler 排在最後,那個 lambda 才會落到它身上,把 requiresAuth 加在 handler 後面,lambda 就變成要傳給 Boolean,那兩個測試檔會冒出一整排 Argument type mismatch: actual type is '() -> RelixResponse', but 'Boolean' was expected,前面幾篇累積的路由測試全部編譯不過

改完欄位順序,換另一批呼叫會抱怨,src/ 底下有三個檔案是用位置參數建 Route 的,第 11 篇的 RouteGroup.kt 八處,第 10 篇的 RoutingBuilder.kt 五處,RelixApplication.kt 四處,長相都像這樣

routes += Route("GET", normalizePath(prefix, path), handler)

第三個位置現在是 requiresAuthhandler 就傳錯格了,這十七處全部要改成具名參數

routes += Route(
    method = "GET",
    path = normalizePath(prefix, path),
    handler = handler,
)

測試那邊也有躲不掉的,RouterTest.kt 的 HEAD fallback 跟 PathParamTest.kt 的靜態路由優先都要動,凡是要斷言「匹配到的是哪一個 handler」,就得先把 handler 宣告成變數才有東西可以比對,這種寫法用不了 trailing lambda

val headHandler: RelixHandler = { ok("head-specific") }
router.add(Route("HEAD", "/hello", handler = headHandler))
router.add(Route("GET", "/hello") { ok("get-handler") })

第一個 add 把 handler 具名傳進去,第二個維持 trailing lambda,兩種混用沒問題,Kotlin 只要求具名參數排在位置參數後面。這兩個檔案裡其他只是註冊路由、不比對 handler 的測試都是 trailing lambda,不受影響

整理起來就是,位置參數等於把呼叫端綁在 data class 的欄位順序上,欄位一多就是這種結果,改成具名之後,Route 以後再加欄位,這些呼叫點都不用再動,只有 trailing lambda 那種寫法是靠 handler 排在最後撐著

然後在 RoutingBuilder 加上 authenticate

fun authenticate(block: RoutingBuilder.() -> Unit) {
    val innerBuilder = RoutingBuilder()
    innerBuilder.block()

    for (route in innerBuilder.build()) {
        this.routes.add(route.copy(requiresAuth = true))
    }
}

authenticate { } 建立一個內部的 RoutingBuilder,在裡面註冊的路由都會被標記 requiresAuth = true,然後合併到外層的 routes 裡,預設是 false,只有在 authenticate { } 裡面註冊的才是 true

middleware 要知道目前打到哪條 route 才知道要不要檢查,這個欄位第 12 篇定義 RelixCall 的時候就備好了,internal var matchedRoute: Route? = null,第 13 篇的匹配階段會把 call.matchedRoute = result.route 寫進去,接著才執行 pipeline,整段流程用的是同一個 call,所以 middleware 直接讀得到

下面的程式一樣都放在 Auth.kt

fun authMiddleware(config: AuthenticationConfig): RelixMiddleware {
    return { next ->
        checkAuth(config) ?: next()
    }
}

internal fun RelixCall.checkAuth(config: AuthenticationConfig): RelixResponse? {
    val route = matchedRoute ?: return null
    if (!route.requiresAuth) return null

    val token = parseBearer(request.header("Authorization")) ?: return unauthorized()
    val principal = config.bearerValidator?.invoke(token) ?: return unauthorized()
    context[PrincipalKey] = principal

    return null
}

internal fun unauthorized() = RelixResponse(
    401,
    mapOf(
        "WWW-Authenticate" to listOf("Bearer"),
        "Content-Type" to listOf("text/plain; charset=utf-8"),
    ),
    "Unauthorized".toByteArray(),
)

checkAuth() 的回傳型別是 RelixResponse?,null 表示放行,非 null 就是要直接回給 client 的拒絕回應。middleware 本體因此只剩一行,checkAuth(config) ?: next()

拆成兩個函式是為了那一連串 early return,認證這件事本質上是「任何一關沒過就馬上結束」,用 ?:return 寫出來,每一行都是一個獨立的關卡,全部擠進 middleware 的 lambda 會變成四層巢狀的 if/else,多一個檢查就再深一層,lambda 裡沒辦法 early return,這是拆出來的直接理由

TDD 確認授權的邊界

認證做完了,/me 已經知道打進來的是誰,這一輪要處理的是「知道你是誰之後,要不要給你進」

測試檔案放 AuthorizationTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

class AuthorizationTest {

    private fun createApp(): RelixApplication {
        val app = RelixApplication()
        app.install(ErrorHandling)
        app.install(Authentication) {
            bearer { token ->
                when (token) {
                    "token-alice" -> UserPrincipal("1", "Alice", setOf("admin"))
                    "token-bob" -> UserPrincipal("2", "Bob", setOf("member"))
                    "token-carol" -> UserPrincipal("3", "Carol", setOf("editor"))
                    else -> null
                }
            }
            roles { principal -> (principal as UserPrincipal).roles }
        }
        app.routing {
            authenticate {
                get("/me") { ok(principal<UserPrincipal>().name) }
            }
            authorize("admin") {
                get("/admin") { ok("admin panel") }
            }
            authorize("admin", "editor") {
                get("/posts") { ok("post editor") }
            }
        }
        return app
    }

    @Test
    fun `authorize implies authentication`() {
        val response = RelixTestKit(createApp()).handleRequest("GET", "/admin")

        assertEquals(401, response.statusCode)
    }

    @Test
    fun `a user with the required role passes`() {
        val response = RelixTestKit(createApp()).handleRequest(
            method = "GET",
            path = "/admin",
            headers = mapOf("Authorization" to listOf("Bearer token-alice")),
        )

        assertEquals(200, response.statusCode)
        assertEquals("admin panel", response.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `a user without the required role gets 403`() {
        val response = RelixTestKit(createApp()).handleRequest(
            method = "GET",
            path = "/admin",
            headers = mapOf("Authorization" to listOf("Bearer token-bob")),
        )

        assertEquals(403, response.statusCode)
        assertEquals("Forbidden", response.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `any one of the listed roles is enough`() {
        val testKit = RelixTestKit(createApp())

        val byAdmin = testKit.handleRequest(
            method = "GET",
            path = "/posts",
            headers = mapOf("Authorization" to listOf("Bearer token-alice")),
        )
        val byEditor = testKit.handleRequest(
            method = "GET",
            path = "/posts",
            headers = mapOf("Authorization" to listOf("Bearer token-carol")),
        )

        assertEquals(200, byAdmin.statusCode)
        assertEquals(200, byEditor.statusCode)
    }

    @Test
    fun `routes without authorize are unaffected by roles`() {
        val response = RelixTestKit(createApp()).handleRequest(
            method = "GET",
            path = "/me",
            headers = mapOf("Authorization" to listOf("Bearer token-bob")),
        )

        assertEquals(200, response.statusCode)
        assertEquals("Bob", response.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `authorize without a roles extractor fails loudly`() {
        val app = RelixApplication()
        app.install(Authentication) {
            bearer { UserPrincipal("1", "Alice", setOf("admin")) }
        }
        app.routing {
            authorize("admin") {
                get("/admin") { ok("admin panel") }
            }
        }

        assertFailsWith<IllegalStateException> {
            RelixTestKit(app).handleRequest(
                method = "GET",
                path = "/admin",
                headers = mapOf("Authorization" to listOf("Bearer any")),
            )
        }
    }
}

第一個測試確認 authorize 包含了 authenticate,沒帶 token 打 /admin 拿到的是 401 不是 403,順序不能反過來,先確定你是誰,才談你能不能做

第二個跟第三個是這一輪的主體,同一個路徑、同樣有效的 token,Alice 進得去 Bob 進不去,差別只在角色

第四個測試釘的是多個角色的語意,authorize("admin", "editor") 是「其中一個就夠」,不是「兩個都要有」,Alice 只有 admin、Carol 只有 editor,兩個都該進得去

第五個測試確認 authenticate { } 不會被角色檢查波及,Bob 沒有 admin,但 /me 本來就沒要求角色,他該拿到 200

最後一個測試比較特別,它斷言的是框架使用者寫錯的時候會爆,用了 authorize("admin") 卻沒設定 roles { },框架根本不知道去哪裡拿角色,這種情況安靜回 403 是最糟的選擇,你會以為權限設錯,翻半天才發現是少寫一段設定,所以這裡直接丟 IllegalStateException

實作 authorize 與角色檢查

Route 再多一個欄位,一樣插在 handler 前面

data class Route(
    val method: String,
    val path: String,
    val requiresAuth: Boolean = false,
    val requiredRoles: Set<String> = emptySet(),
    val handler: RelixHandler,
)

RoutingBuilderauthorizeauthenticate 是同一個手法,差別在它同時設定兩個欄位

fun authorize(vararg roles: String, block: RoutingBuilder.() -> Unit) {
    val innerBuilder = RoutingBuilder()
    innerBuilder.block()

    for (route in innerBuilder.build()) {
        val inherited = route.requiredRoles.ifEmpty { roles.toSet() }
        this.routes.add(
            route.copy(requiresAuth = true, requiredRoles = inherited),
        )
    }
}

requiresAuth = true 一起設定,這就是「authorize 包含 authenticate」在程式碼裡的樣子,使用者不用把兩層疊起來寫

inherited 那三行處理的是巢狀。內層已經有角色要求時保留內層的,不跟外層做聯集。因為這裡的多角色是 OR,一聯集,authorize("admin") { authorize("editor") { } } 會變成 admin 或 editor 都能進,巢狀反而放寬了限制,跟直覺相反。讓內層優先,巢狀的語意就是「以最靠近路由的那個宣告為準」

config 加上角色的取得方式

class AuthenticationConfig {
    internal var bearerValidator: ((String) -> Principal?)? = null
    internal var roleExtractor: ((Principal) -> Set<String>)? = null

    fun bearer(validate: (String) -> Principal?) {
        bearerValidator = validate
    }

    fun roles(extract: (Principal) -> Set<String>) {
        roleExtractor = extract
    }
}

Principal 仍然是那個空介面,框架不規定角色放在哪個欄位、叫什麼名字,甚至不規定角色一定要存在 principal 裡,roles { } 拿到 principal 之後去查資料庫也是合法的

最後把角色檢查接到 checkAuth() 後半段

internal fun RelixCall.checkAuth(config: AuthenticationConfig): RelixResponse? {
    val route = matchedRoute ?: return null
    if (!route.requiresAuth) {
        return null
    }

    val token = parseBearer(request.header("Authorization")) ?: return unauthorized()
    val principal = config.bearerValidator?.invoke(token) ?: return unauthorized()
    context[PrincipalKey] = principal

    if (route.requiredRoles.isEmpty()) {
        return null
    }

    val extract = config.roleExtractor
        ?: error("authorize() needs a roles { } extractor in the Authentication config")
    val granted = extract(principal)
    return if (route.requiredRoles.any { it in granted }) null else forbidden()
}

internal fun forbidden() = RelixResponse(
    403,
    mapOf("Content-Type" to listOf("text/plain; charset=utf-8")),
    "Forbidden".toByteArray(),
)

新增的部分接在 context[PrincipalKey] = principal 後面,順序是刻意的,角色不過的時候 principal 已經放進 context 了,後面的 middleware 或錯誤處理如果想知道「是誰被擋下來」,拿得到人

route.requiredRoles.any { it in granted } 就是 OR 的實作,要改成 AND 只要換成 granted.containsAll(route.requiredRoles),一行的差別,但 API 的語意會完全不同,這種決定要在寫測試的時候就想清楚

error() 丟的是 IllegalStateException,不是 RelixHttpException,這個區別是故意的,RelixHttpException 會被 ErrorHandling 翻成 HTTP 回應,但設定漏寫不是 client 的錯,不該變成一個 5xx 回應然後被當成偶發錯誤忽略掉,它應該在開發階段第一次打這條路由就炸開

401 和 403 的語意

Status 意思 誰負責 回應 header
401 沒有認證或認證無效 Auth middleware WWW-Authenticate: Bearer
403 已認證但權限不足 Auth middleware 無特殊 header

401 表示「再試一次,帶上正確的 token」,403 表示「你的 token 沒問題,但你就是不能做這件事」

常見錯誤是把「沒帶 token」當 403,RFC 7235 很明確,沒認證是 401,不是 403,403 暗示 server 知道你是誰 (已認證),但不給你進

在 main 裡組起來跑一次

UserPrincipal 是應用層的型別,前面為了測試方便寫在 test/ 底下,這裡要搬到 src/main() 才用得到

fun main() {
    val app = RelixApplication()

    app.install(Logging) {
        logger = ConsoleLogger()
    }
    app.install(ErrorHandling)
    app.install(ContentNegotiation) {
        json()
    }
    app.install(Authentication) {
        bearer { token ->
            when (token) {
                "token-alice" -> UserPrincipal("1", "Alice", setOf("admin"))
                "token-bob" -> UserPrincipal("2", "Bob", setOf("member"))
                else -> null
            }
        }
        roles { principal -> (principal as UserPrincipal).roles }
    }

    app.routing {
        get("/public") { ok("hello") }

        authenticate {
            get("/me") { ok(principal<UserPrincipal>().name) }
        }

        authorize("admin") {
            get("/admin") { ok("admin panel") }
        }
    }

    JdkHttpServerAdapter(app).start(8080)
}

bearer { } 裡的 token 是寫死的,正式環境這裡會去查資料庫或驗 JWT 簽章,但 middleware 那一層不用知道差別,它只要拿到 Principalnull

公開路由,不帶 token

curl -i localhost:8080/public
HTTP/1.1 200 OK
Content-type: text/plain; charset=utf-8
Content-length: 5

hello

/public 沒有寫在 authenticate { }authorize { } 裡面,requiresAuthfalse,middleware 第二行就放行了

保護路由,不帶 token

curl -i localhost:8080/me
HTTP/1.1 401 Unauthorized
Www-authenticate: Bearer
Content-type: text/plain; charset=utf-8
Content-length: 12

Unauthorized

header 名稱印出來是 Www-authenticate 不是我們寫的 WWW-Authenticate,這是 JDK HttpServer 在 Headers 這個容器裡做的正規化,它會把每個 header 名稱統一成首字母大寫,HTTP header 名稱本來就是 case-insensitive,client 不會因此認不得

帶有效 token

curl -i -H "Authorization: Bearer token-alice" localhost:8080/me
HTTP/1.1 200 OK
Content-type: text/plain; charset=utf-8
Content-length: 5

Alice

帶有效 token,但角色不對

curl -i -H "Authorization: Bearer token-bob" localhost:8080/admin
HTTP/1.1 403 Forbidden
Content-type: text/plain; charset=utf-8
Content-length: 9

Forbidden

換成有 admin 角色的 token

curl -i -H "Authorization: Bearer token-alice" localhost:8080/admin
HTTP/1.1 200 OK
Content-type: text/plain; charset=utf-8
Content-length: 11

admin panel

後面這兩組的對比就是 401 跟 403 的差別,Bob 的 token 完全有效,middleware 也認得他,是角色不符才被擋在 handler 之外,/admin 的 handler 從頭到尾沒有執行過

log 五筆

[Relix] GET /public -> 200 (0ms)
[Relix] GET /me -> 401 (0ms)
[Relix] GET /me -> 200 (3ms)
[Relix] GET /admin -> 403 (0ms)
[Relix] GET /admin -> 200 (0ms)

401 跟 403 那兩筆都是 auth middleware 短路掉的,request 沒有進到 handler,logging 一樣記得到,因為 Logging 裝在 Authentication 前面,它包在整條 pipeline 的最外層

第三筆的 3ms 是 JVM 的暖機,/me 這一筆是整輪裡第一個真的走完 handler 的 request,principal<UserPrincipal>()ok() 都是第一次執行,還沒被 JIT 編譯,後面那兩筆走同一段路就回到 0ms 了

常見陷阱與設計取捨

Ktor 自己有 role-based authorization 嗎 ?

沒有,Ktor 的 auth plugin 給的是 authenticate("name") 這種具名 provider,那仍然屬於認證,選的是「用哪一套方式驗身份」,角色檢查在 Ktor 官方文件裡的做法是自己在 route 裡面 intercept,或是搭配 StatusPages 定義自己的 AuthorizationException 再翻成 403

也就是說,這篇的 authorize { } 是 Relix 比 Ktor 多走的一步,真實框架不做的理由通常是權限模型差異太大,RBAC、ABAC、資源層級的 ownership 檢查,很難用一組 API 全部涵蓋,Relix 這裡選了最單純的 RBAC,換來的是路由層看得到權限宣告

為什麼 403 從 handler 移到 middleware ?

宣告在路由上的權限,讀路由就看得完,權限判斷留在 handler 裡的時候,你得打開每一個 handler 才知道誰能打,而且很容易漏掉一個,漏掉的那個就是安全漏洞

但這不表示 handler 裡不再需要任何權限判斷,/users/{id} 只允許本人存取這種規則,跟角色無關,它要比對的是 path parameter 跟 principal 的 userId,這種 middleware 幫不上忙,還是得在 handler 裡寫,角色進 middleware,資源層級的 ownership 留在 handler,這條線大致就是這樣分

拿 Spring Security 來對照,它把這個分工做成兩個獨立元件,AuthenticationEntryPoint 處理「沒身份」 (401,引導 client 去登入或補 token),AccessDeniedHandler 處理「有身份但沒權限」 (403,無論補什麼 token 都不會通過)

為什麼不把 token 驗證做成 suspend ?

這裡的 bearer validator 是 (String) -> Principal? 同步函式,真實場景可能需要查資料庫或打外部 API,第 14 篇升級 pipeline 成 suspend 之後,validator 也可以改成 suspend (String) -> Principal?roles { } 也一樣。不過這裡先使用同步,降低認知負擔

角色用字串會不會太鬆 ?

會,authorize("admni") 這種 typo compiler 完全攔不住,要到 runtime 才會發現所有人都被擋在外面,做嚴一點的話可以換成 enum 或 sealed class,代價是框架得先知道應用有哪些角色,或是多一層泛型,這跟第 22 篇 matches(regex) 把 pattern 直接放進錯誤訊息是同一類取捨,這裡先使用字串

小結

認證跟授權在 Relix 裡是同一個 middleware 的前後兩段,前段把 Bearer token 換成 Principal 放進 CallContext,失敗回 401,後段拿 Principal 的角色比對路由的 requiredRoles,不符回 403

路由層看得到兩個標記,authenticate { } 要求有效身份,authorize("admin") { } 在此之上再要求角色,Principal 維持成一個空介面,角色怎麼存、從哪裡拿,由應用層的 roles { } 決定


下一篇

下一篇會做 Static File Serving,看起來簡單,但會碰到 MIME type 判斷、ETag/304 快取、path traversal 安全防護


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 22 Request Validation,輸入驗證機制
下一篇
Kotlin 手刻 Ktor 從零開始 Day 24 Static File Serving,靜態檔案服務
系列文
Kotlin 手刻 Ktor 從零開始26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言