
授權要做的事很單純,拿到 principal,看它有沒有那個角色,沒有就擋掉,讓角色從設定檔一路簽進 JWT 的 claim,再寫一個 route-scoped plugin 在 route 上做檢查
application.yaml 進來,簽進 JWT 的 roles claim/refresh 重簽的時候角色全部掉光,修法是回名單重查而不是抄 tokenonCall 讀不到 principal,正確的鉤子是 on(AuthenticationChecked)
Authorization 這個 route-scoped plugin,跟一個 authorize(vararg roles)
DELETE /todos/{id} 3 種答案,204、403、401,403 的 WWW-Authenticate 跟 body 都有講究authorize 放錯一層會把 405 變成 404,Ktor 自己的 authenticate 也一樣authorize 沒有包在 authenticate 裡面的時候,所有人都被擋成 401authorize 要不要包含 authenticate
Relix day 23 那篇自問自答過這題,答案是沒有,authenticate("name") 選的是「用哪一套方式驗身份」,仍然屬於認證,角色檢查在官方文件裡的做法是自己在 route 裡 intercept,或是搭配 StatusPages 把自訂的 exception 翻成 403
這篇在 Ktor 這邊確認同一件事,ktor-server-auth 裡沒有任何 authorize,但它有 ForbiddenResponse 這種零件,也就是「403 這個回應長什麼樣」它幫你想好了,「誰該拿到 403」它不管
所以這一步要自己走,用的是 createRouteScopedPlugin,day 10 寫 RequestTiming 用的是 application 層級的 createApplicationPlugin,route-scoped 那個版本在 day 12 拆 ContentNegotiation、day 14 拆 RequestValidation 的原始碼時都看過,這篇是第 1 次自己寫一個
先讓使用者有角色,src/main/resources/application.yaml 的 todo.auth.users 底下,2 個人各多一個 roles
users:
- name: "$ALICE_NAME:alice"
password: "$ALICE_PASSWORD"
+ roles:
+ - user
+ - admin
- name: "$BOB_NAME:bob"
password: "$BOB_PASSWORD"
+ roles:
+ - user
alice 是 user 加 admin,bob 只有 user
src/main/kotlin/com/cashwu/todo/TodoConfig.kt 那邊只多一個欄位
data class AuthUser(
val name: String,
val password: String,
+ val roles: List<String>,
)
day 17 那套 getAs<TodoConfig>() 一樣吃得下巢狀的 List<String>,沒有多寫任何轉換,ConfigurationTest 裡那個把整份設定比對一次的測試補上期望值就好
接著是 src/main/kotlin/com/cashwu/todo/Auth.kt
principal 的型別多一個欄位,claim 的名字多一個常數
const val REFRESH_TOKEN = "refresh"
+const val ROLES_CLAIM = "roles"
User 多了 roles 的欄位,而且給了預設值 emptySet(),所以 TodoUser("alice") 這種舊寫法還是編得過,只是那個人什麼角色都沒有
@Serializable
data class TodoUser(val name: String, val roles: Set<String> = emptySet())
同一個檔案裡,day 27 那個比對密碼的 PasswordAuthenticator,這一步只動它的內部
class PasswordAuthenticator(users: List<AuthUser>) {
init {
require(users.map(AuthUser::name).distinct().size == users.size) {
"Duplicate user names are not allowed"
}
}
- private val entries = users.map { it.name to it.password.toByteArray(Charsets.UTF_8) }
+ private val entries = users.map { TodoUser(it.name, it.roles.toSet()) to it.password.toByteArray(Charsets.UTF_8) }
fun authenticate(name: String, password: String): TodoUser? {
val candidate = password.toByteArray(Charsets.UTF_8)
var matched: TodoUser? = null
for ((user, expected) in entries) {
val passwordMatches = MessageDigest.isEqual(expected, candidate)
- if (user == name && passwordMatches) {
- matched = TodoUser(user)
+ if (user.name == name && passwordMatches) {
+ matched = user
}
}
return matched
}
}
entries 從「名字對密碼」變成「TodoUser 對密碼」,所以登入成功之後回的那個 TodoUser 一出來就帶著角色,day 27 那 2 個刻意的寫法沒有變,密碼比較仍然先執行,迴圈也還是跑完整份名單而不是提早跳出
類別名字跟 authenticate 的簽章都沒有動,所以 Application.kt 跟 tokenRoutes 這一步一行都不用改,改名是下一節的事
再來是簽跟驗
簽的那一邊,TokenIssuer class 裡面的 sign fun 裡多一行
.withClaim(TOKEN_USE_CLAIM, type)
+ .withArrayClaim(ROLES_CLAIM, user.roles.sorted().toTypedArray())
sorted() 不是為了好看,roles 宣告成 Set,而 Set 這個介面本身不保證遍歷順序,簽進 token 的陣列順序如果每次不一樣,測試就得寫成「比對集合」而不是「比對這串 JSON」,排過之後 token 的內容對同一個人是穩定的
驗的那一邊,AuthenticationConfig.todoAuth fun 裡面的 validate,改成把 claim 讀回來組成 principal
val name = credential.payload.subject
- if (type == ACCESS_TOKEN && name != null) TodoUser(name) else null
+ val roles = credential.payload.getClaim(ROLES_CLAIM).asList(String::class.java).orEmpty()
+ if (type == ACCESS_TOKEN && name != null) TodoUser(name, roles.toSet()) else null
asList 在 claim 不存在的時候回 null,所以後面接 orEmpty(),完全沒有 roles claim 的舊 token 因此不會炸,它會變成一個沒有任何角色的 TodoUser,能進得了 authenticate,過不了任何 authorize,這一次沒有測到那條路,因為 sign 一定會呼叫 withArrayClaim,測試簽得出來的最少是一個空陣列,不是缺 claim
角色到這裡算是接上了,先跑測試,./gradlew test 這時候連編譯都過不了,AuthUser 多的那個 roles 沒有預設值,ConfigurationTest 裡建 AuthUser 的那 2 行要補上角色
- AuthUser("alice", "test-alice-secret"),
- AuthUser("bob", "test-bob-secret"),
+ AuthUser("alice", "test-alice-secret", listOf("user", "admin")),
+ AuthUser("bob", "test-bob-secret", listOf("user")),
補完再跑,這次編得過了,不過測試還是有問題的,先只跑跟身分有關的那幾個 class,2 個失敗,訊息一模一樣
AuthenticationTest > each token resolves to its own user() FAILED
org.opentest4j.AssertionFailedError: expected: <{"name":"alice"}> but was: <{"name":"alice","roles":[]}>
JwtTest > refresh answers a new pair that works() FAILED
org.opentest4j.AssertionFailedError: expected: <{"name":"alice"}> but was: <{"name":"alice","roles":[]}>
整包跑的話還會多幾個 RequestValidationTest 之類的失敗,那些是連鎖反應不是新問題,整套 API 層的測試共用 jdbc:h2:mem:todo 這一個資料庫名字,H2 的 in-memory 資料庫要等最後一條連線關掉才消滅,只要有連線池還沒關乾淨,下一個測試就會看到上一個留下的資料,筆數就對不上,day 26 那篇撞過一次,兇手是 3 個還沒補 token 的測試,等這一輪修完它們會自己消失,這件事後面 405 那一節還會再遇到一次
先改 AuthenticationTest 的相關測試
/me 回的就是 principal,principal 多一個欄位,凡是拿 /me 的 JSON 做完整比對的測試都要跟著補,這個測試自己簽 token,用的是 TodoUser("alice"),走的是那個預設值,所以角色是空的,先讓它簽一張真的有角色的票
@Test
fun `each token resolves to its own user`() = testApplication {
todoApplication()
val alice = testIssuer.accessToken(TodoUser("alice", setOf("user", "admin")))
val bob = testIssuer.accessToken(TodoUser("bob", setOf("user")))
assertEquals("""{"name":"alice","roles":["admin","user"]}""", bearerClient(alice).get("/me").bodyAsText())
assertEquals("""{"name":"bob","roles":["user"]}""", bearerClient(bob).get("/me").bodyAsText())
}
TestApp.kt 那個共用的 TEST_USER 也一起,它現在把設定檔裡的 roles 帶進來,所以 TEST_TOKEN 從這篇開始是一張帶 admin 的票
-val TEST_USER: TodoUser = TodoUser(todoTestConfig.auth.users.first().name)
+val TEST_USER: TodoUser = todoTestConfig.auth.users.first().let { TodoUser(it.name, it.roles.toSet()) }
JwtTest 那個要改的不一樣,refresh answers a new pair that works 不自己簽 token,票是 /login 換 /refresh 換來的,所以只有 /me 那一行期望值要動
- assertEquals("""{"name":"alice"}""", bearerClient(second.accessToken).get("/me").bodyAsText())
+ assertEquals(
+ """{"name":"alice","roles":["admin","user"]}""",
+ bearerClient(second.accessToken).get("/me").bodyAsText(),
+ )
這一行改完它測試不會跟著通過,因為問題不在期望值這一邊,下一章會修正
上面那些補完再跑一次,身分那幾個 class 只剩一個紅的,而且訊息換了一個樣子
JwtTest > refresh answers a new pair that works() FAILED
org.opentest4j.AssertionFailedError: expected: <{"name":"alice","roles":["admin","user"]}> but was: <{"name":"alice","roles":[]}>
期望值已經補成有角色的了,實際拿到的還是空的,alice 拿 refresh token 換一組新的,換回來的那張 access token 上,角色是空的
原因在 day 27 的 /refresh 那一行,它驗完 refresh token 之後,只拿了 sub 出來重簽
call.respond(issuer.tokensFor(TodoUser(payload.subject)))
TodoUser(payload.subject) 就是前面那個有預設值的建構子,第 2 個參數沒給,角色就是 emptySet(),day 27 的時候 TodoUser 只有一個欄位,這行是對的,這篇多了一個欄位之後它就變成 bug 了
修法在 src/main/kotlin/com/cashwu/todo/Auth.kt 的 tokenRoutes
不過先決定走哪一條,因為 2 條路要動的東西不一樣,這裡有 2 條路可以走,選哪一條是有差別的
第 1 條是把舊 token 裡的 roles claim 讀出來,原封不動簽進新的 token,這條最短,一行就好,而且完全不用碰那個比對密碼的類別
第 2 條是拿 sub 回名單重查一次,重查到什麼就簽什麼
差別在一個被降權的人身上,refresh token 活 7 天,如果角色是從舊 token 抄過來的,一個今天被拿掉 admin 的人,手上那張還沒過期的 refresh token 可以一路換到下週,每次都換回一張有 admin 的 access token,token 裡的角色是簽發那一刻的快照,不是現在的事實,拿快照去簽新的票,等於讓那個快照永遠續命
所以走的是第 2 條,也因為要「不帶密碼查一個人」,那個類別多了 find,名字也從 PasswordAuthenticator 改成 UserDirectory,因為它現在管的是整份名單,比對密碼只是其中一件事
-class PasswordAuthenticator(users: List<AuthUser>) {
+class UserDirectory(users: List<AuthUser>) {
init {
require(users.map(AuthUser::name).distinct().size == users.size) {
"Duplicate user names are not allowed"
}
}
private val entries = users.map { TodoUser(it.name, it.roles.toSet()) to it.password.toByteArray(Charsets.UTF_8) }
+ fun find(name: String): TodoUser? = entries.firstOrNull { it.first.name == name }?.first
// ...
}
find 只認名字,不碰密碼,這是它跟 authenticate 的分工
tokenRoutes 的簽章跟 /login 裡那一行呼叫都跟著換名字
-fun Route.tokenRoutes(authenticator: PasswordAuthenticator, issuer: TokenIssuer) {
+fun Route.tokenRoutes(directory: UserDirectory, issuer: TokenIssuer) {
post("/login") {
val request = call.receive<LoginRequest>()
- val user = authenticator.authenticate(request.name, request.password)
+ val user = directory.authenticate(request.name, request.password)
// ...
}
// ...
}
src/main/kotlin/com/cashwu/todo/Application.kt 那 3 行也只是換名字
- provide<PasswordAuthenticator> { PasswordAuthenticator(resolve<TodoConfig>().auth.users) }
+ provide<UserDirectory> { UserDirectory(resolve<TodoConfig>().auth.users) }
- val authenticator: PasswordAuthenticator by dependencies
+ val directory: UserDirectory by dependencies
- tokenRoutes(authenticator, issuer)
+ tokenRoutes(directory, issuer)
名字都改好了,在 Auth Route.tokenRoutes 的 /refresh 才可以改
- call.respond(issuer.tokensFor(TodoUser(payload.subject)))
+ val user = directory.find(payload.subject)
+ ?: throw ApiException(HttpStatusCode.BadRequest, "這個使用者已經不在了")
+ call.respond(issuer.tokensFor(user))
查不到就是這個人已經從名單上消失了,這時候回的是 400 加「這個使用者已經不在了」,走的是 day 15 那個 ApiException,跟 day 27 決定的「token endpoint 的失敗回 400」同一條路
./gradlew test 這時候整包是綠的,剛才那幾個連鎖反應的失敗也跟著不見了
改到這裡程式碼才是完整的,這時候起 server 才有意義
資料庫還是 day 23 那個 docker compose 起的 PostgreSQL,指令跟 day 27 一樣,前面換掉的是那 3 個沒有預設值的憑證
docker compose up -d
JWT_SECRET=local-dev-secret-32-bytes-minimum ALICE_PASSWORD=alice-secret BOB_PASSWORD=bob-secret \
DB_PASSWORD=todo ./gradlew run
2 個人各登入一次,2 張 access token 放進 shell 變數,這一節到後面幾節的輸出都是這一組打出來的
curl -s -X POST localhost:8080/login \
-H 'Content-Type: application/json' \
-d '{"name":"alice","password":"alice-secret"}' > /tmp/alice.json
curl -s -X POST localhost:8080/login \
-H 'Content-Type: application/json' \
-d '{"name":"bob","password":"bob-secret"}' > /tmp/bob.json
ALICE=$(python3 -c 'import json; print(json.load(open("/tmp/alice.json"))["accessToken"])')
BOB=$(python3 -c 'import json; print(json.load(open("/tmp/bob.json"))["accessToken"])')
把 alice 那張的第 2 段用 day 27 那個 base64url 的解法解開,roles 已經在裡面了
{"iss":"todo-api","aud":"todo-api-client","sub":"alice","token_use":"access","roles":["admin","user"],"iat":1788509371,"exp":1788510271}
payload 誰都解得開,角色放在 token 裡不是秘密,它只是「不能被改」,改一個字元簽章就對不上,所以 roles 這種東西可以放,密碼跟個資還是不能放
/me 因為回的就是 principal 本身,不用改任何一行就跟著多了角色
curl -i -s -H "Authorization: Bearer $BOB" localhost:8080/me
HTTP/1.1 200 OK
X-Request-Id: oyyqds4vsq6o
X-Response-Time: 9ms
Content-Length: 31
Content-Type: application/json
{"name":"bob","roles":["user"]}
第 1 版是照 day 10 的習慣寫的,一個 create*Plugin 加一個 onCall,然後發現它擋掉了所有人,包含 admin,問題不在角色比對,在 call.principal<TodoUser>() 那一行,它回的是 null,而同一個請求的 handler 讀到的是 alice
這件事後來寫成 2 個 probe plugin 並排的一個測試
新增一個檔案 src/test/kotlin/com/cashwu/todo/AuthorizationTest.kt,檔案最上面是 2 個人跟 2 個 probe,2 個 plugin 什麼都不做,只是把它們看到的名字記下來
private val ALICE = TodoUser("alice", setOf("user", "admin"))
private val BOB = TodoUser("bob", setOf("user"))
private val seenByOnCall = mutableListOf<String?>()
private val seenAfterAuth = mutableListOf<String?>()
private val OnCallProbe = createRouteScopedPlugin("OnCallProbe") {
onCall { call -> seenByOnCall.add(call.principal<TodoUser>()?.name) }
}
private val AfterAuthProbe = createRouteScopedPlugin("AfterAuthProbe") {
on(AuthenticationChecked) { call -> seenAfterAuth.add(call.principal<TodoUser>()?.name) }
}
ALICE 跟 BOB 是直接寫在測試裡的 TodoUser,不是從設定檔讀的,這是刻意的,這個檔案要測的是「有這些角色的人會怎樣」,不是「設定檔裡的人有哪些角色」,後面那件事是 ConfigurationTest 的工作
2 個 probe 記在檔案層級的 list 上,跨測試會累積,所以 class 裡要有一個 @BeforeTest 把它們清乾淨
@BeforeTest
fun clearProbes() {
seenByOnCall.clear()
seenAfterAuth.clear()
}
要量的是各種光禿禿的 route 形狀,所以還要一個自己的 helper,它把預設的 module 關掉,只裝一個 Authentication,其餘的 routing 由每個測試自己接,testIssuer 是 day 27 放進 TestApp.kt 的那個測試用簽發器
private fun ApplicationTestBuilder.probeApplication(block: Application.() -> Unit) {
configure(overrides = { put("ktor.application.modules.size", "0") })
application {
install(Authentication) { todoAuth(todoTestConfig.auth, testIssuer) }
block()
}
}
測試本身把 2 個 probe 一起裝在同一個 authenticate(TODO_AUTH) 底下,帶一張有效的 token 打進去
@Test
fun `oncall runs before authentication and the auth hook runs after`() = testApplication {
probeApplication {
routing {
authenticate(TODO_AUTH) {
install(OnCallProbe)
install(AfterAuthProbe)
get("/probe") { call.respondText(call.principal<TodoUser>()!!.name) }
}
}
}
assertEquals("alice", bearerClient(testIssuer.accessToken(ALICE)).get("/probe").bodyAsText())
assertEquals(listOf<String?>(null), seenByOnCall)
assertEquals(listOf<String?>("alice"), seenAfterAuth)
}
同一個請求,onCall 讀到 null、AuthenticationChecked 讀到 alice、handler 也讀到 alice
授權要讀 principal,principal 是認證放上去的,所以授權必須跑在認證之後,用 onCall 寫的話跑得太早
AuthenticationHook 攔在 Validators 這個 phase,而 day 10 介紹 hook 的時候寫的是「onCall,請求進來時執行,掛在 Plugins phase,就是上一篇測過「一定比 routing 早」的那個位置」,Plugins 排在 Validators 前面,所以 onCall 跑的時候,認證還沒開始
要跑在認證之後,用的是 ktor-server-auth 自己提供的 hook,AuthenticationChecked 在 AuthenticationInterceptors.kt 裡,把 KDoc 跟一個 @Suppress 拿掉之後,本體是這樣
public object AuthenticationChecked : Hook<suspend (ApplicationCall) -> Unit> {
internal val AfterAuthenticationPhase: PipelinePhase = PipelinePhase("AfterAuthentication")
override fun install(
pipeline: ApplicationCallPipeline,
handler: suspend (ApplicationCall) -> Unit
) {
pipeline.insertPhaseAfter(ApplicationCallPipeline.Validators, AfterAuthenticationPhase)
pipeline.intercept(AfterAuthenticationPhase) { handler(call) }
}
}
它是一個 Hook,而且自己帶了一個 AfterAuthenticationPhase,insertPhaseAfter 那一行把位置寫死了,這個 phase 就插在 Validators 後面,而 Validators 正是 AuthenticationHook 攔的那個 phase,所以掛在上面的程式碼看得到認證的結果
這裡要注意的是「認證跑完」不等於「認證成功」,它的 KDoc 自己就寫了 this hook is also executed for optional authentication or for routes without any authentication, resulting in [ApplicationCall.principal] being null,也就是沒帶 token 的請求一樣會跑到,只是那時候 call.principal<TodoUser>() 是 null,plugin 因此要有 2 條分支,沒有身分是一條,有身分但角色不夠是另一條
Hook 的型別參數是 suspend (ApplicationCall) -> Unit,掛上去的 lambda 是 suspend 的,裡面可以直接 call.respond
新增檔案 src/main/kotlin/com/cashwu/todo/Authorization.kt,全文是這樣
package com.cashwu.todo
import io.ktor.http.auth.HeaderValueEncoding
import io.ktor.http.auth.HttpAuthHeader
import io.ktor.server.application.createRouteScopedPlugin
import io.ktor.server.application.install
import io.ktor.server.auth.AuthenticationChecked
import io.ktor.server.auth.ForbiddenResponse
import io.ktor.server.auth.UnauthorizedResponse
import io.ktor.server.auth.principal
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.RouteSelector
import io.ktor.server.routing.RouteSelectorEvaluation
import io.ktor.server.routing.RoutingResolveContext
import io.ktor.util.logging.KtorSimpleLogger
const val ADMIN_ROLE = "admin"
private val authorizationLogger = KtorSimpleLogger("com.cashwu.todo.authorization")
class AuthorizationConfig {
var roles: Set<String> = emptySet()
var realm: String = "todo-api"
}
val Authorization = createRouteScopedPlugin("Authorization", ::AuthorizationConfig) {
val required = pluginConfig.roles
val realm = pluginConfig.realm
on(AuthenticationChecked) { call ->
val user = call.principal<TodoUser>()
if (user == null) {
authorizationLogger.debug("Authorization failed: no principal, ${required.size} role(s) required")
call.respond(
UnauthorizedResponse(
HttpAuthHeader.Parameterized(
"Bearer",
mapOf(HttpAuthHeader.Parameters.Realm to realm),
HeaderValueEncoding.QUOTED_ALWAYS,
)
)
)
return@on
}
if (!user.roles.containsAll(required)) {
authorizationLogger.debug(
"Authorization failed: ${user.name} has ${user.roles.sorted()} but needs ${required.sorted()}"
)
call.respond(
ForbiddenResponse(
HttpAuthHeader.Parameterized(
"Bearer",
mapOf(
HttpAuthHeader.Parameters.Realm to realm,
"error" to "insufficient_scope",
"scope" to required.sorted().joinToString(" "),
),
HeaderValueEncoding.QUOTED_ALWAYS,
)
)
)
}
}
}
private class AuthorizationRouteSelector(private val roles: Set<String>) : RouteSelector() {
override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation =
RouteSelectorEvaluation.Transparent
override fun toString(): String = "(authorize ${roles.sorted().joinToString(", ")})"
}
fun Route.authorize(vararg roles: String, build: Route.() -> Unit): Route {
val required = roles.toSet()
val child = createChild(AuthorizationRouteSelector(required))
child.install(Authorization) { this.roles = required }
child.build()
return child
}
分成 3 塊看
第 1 塊是設定物件跟 plugin 本體,createRouteScopedPlugin 的第 2 個參數是設定物件的建構子,所以每一個裝上去的地方可以有自己的 roles,pluginConfig 那 2 行讀出來之後就存成區域變數,plugin 安裝的時候讀一次,不是每個請求讀一次
UnauthorizedResponse 是 day 26 就在用的那個,ForbiddenResponse 是它同一個檔案旁邊的兄弟,2 個都是 OutgoingContent.NoContent,也就是一個「只有 status 跟 header、沒有 body」的回應,body 是誰補的下一節會講
HeaderValueEncoding.QUOTED_ALWAYS 是 day 27 還債的時候換上去的那個編碼策略,這裡沿用,讓 realm 一樣帶引號
第 2 塊是 AuthorizationRouteSelector,它的 evaluate 回 RouteSelectorEvaluation.Transparent,意思是「這個節點不吃路徑上的任何一段,比對到這裡就直接往下走」,toString 是給 routing 的 trace 看的,出問題的時候可以在路由樹上看到 (authorize admin) 這樣一行
第 3 塊是 authorize 自己,做的事只有 3 步,開一個透明的子節點、把 plugin 裝在那個子節點上、把使用者寫的 route 掛進去,函式簽章上只有角色,realm 留在 AuthorizationConfig 的預設值裡,呼叫端不必每次都寫一次
開透明子節點這一招不是自己發明的,Ktor 的 authenticate 就是這樣做的,它的 AuthenticationRouteSelector 的 evaluate 一樣是回 RouteSelectorEvaluation.Transparent,toString 一樣是給 trace 看的一行字,兩邊同一個形狀,這件事在後面 405 那一節會變成關鍵
還有一個語意要先說清楚,containsAll 是 AND,authorize("a", "b") 的意思是這 2 個角色都要有,不是有其中一個就好,多角色的寫法讀起來 2 種都像,所以這是自己定的,選了哪一種要講出來
裝上去的地方是 src/main/kotlin/com/cashwu/todo/TodoRoutes.kt 的刪除那個
delete("{id}") {
authorize(ADMIN_ROLE) {
delete{
val id = call.todoId()
if (!repository.delete(id)) {
throw ApiException(HttpStatusCode.NotFound, "找不到 id $id 的待辦")
}
call.respond(HttpStatusCode.NoContent)
}
}
}
多包的那一層 route("{id}") 不是排版,拿掉它會壞掉別的東西,理由在後面
同一條 DELETE /todos/{id},3 種人打進去,前 2 個打的是 /todos/1,第 3 個打的是 /todos/2,因為 alice 那次 204 已經把 id 1 刪掉了,而授權在 handler 之前就結案,換哪一筆 id 都不影響答案
bob 有 token、有 user、沒有 admin
curl -i -s -X DELETE -H "Authorization: Bearer $BOB" localhost:8080/todos/1
HTTP/1.1 403 Forbidden
X-Request-Id: jgh29dxe3oqh
X-Response-Time: 2ms
WWW-Authenticate: Bearer realm="todo-api", error="insufficient_scope", scope="admin"
Content-Length: 0
狀態碼是對的,challenge 也帶了,但是一個 byte 的 body 都沒有
alice 有 admin
curl -i -s -X DELETE -H "Authorization: Bearer $ALICE" localhost:8080/todos/1
HTTP/1.1 204 No Content
X-Request-Id: 3-tnqimp1+3n
X-Response-Time: 19ms
完全沒帶 token
curl -i -s -X DELETE localhost:8080/todos/2
HTTP/1.1 401 Unauthorized
X-Request-Id: c9e3bx5/gc4f
X-Response-Time: 0ms
WWW-Authenticate: Bearer realm="todo-api"
Content-Length: 58
Content-Type: application/json
{"status":401,"message":"請帶著有效的 token 再來"}
401 有 body、403 沒有,這個差別不在 plugin,UnauthorizedResponse 跟 ForbiddenResponse 都是 NoContent,兩邊的 body 本來就都不是它們給的,401 那份是 day 26 在 src/main/kotlin/com/cashwu/todo/ErrorHandling.kt 補的 status(HttpStatusCode.Unauthorized) 區塊填的,403 沒有對應的區塊,所以就停在 0
順便看一下 401 那 58 個 byte,details 沒有出現,那也是 day 26 留下的形狀,跟其他錯誤差一個欄位,那篇當時解釋過理由是用了裸的 Json
2 件事一起補,這一段不能呼叫 day 15 的 respondError,理由跟 day 26 的 401 一樣,所以那邊那段固定 JSON 的寫法在這裡抽成一個共用的 helper,補到 ErrorHandling.kt 裡面
private val errorJson = Json { encodeDefaults = true }
private suspend fun ApplicationCall.respondFixedJson(
status: HttpStatusCode,
message: String,
) {
respondText(
text = errorJson.encodeToString(ErrorResponse(status.value, message)),
contentType = ContentType.Application.Json,
status = status,
)
}
2 個 status 區塊都走它,同一個檔案下面, Unauthorized 改成下面這樣,多加一個 Forbidden
status(HttpStatusCode.Unauthorized) { call, status ->
call.respondFixedJson(status, "請帶著有效的 token 再來")
}
status(HttpStatusCode.Forbidden) { call, status ->
call.respondFixedJson(status, "這個身分不能做這件事")
}
respondError 送的是 respond,那條路會把 ErrorResponse 交給 ContentNegotiation,而 ContentNegotiation 認得請求的 Accept,day 26 為 401 量過這件事,那次直接用 respondError 補 body,todo with unsupported accept responds not acceptable 那個測試碰巧通過了,因為 401 被改寫成了 406,403 沒有理由是例外,狀態碼跟 challenge 在授權那一層就已經定了,不該再讓 client 的 Accept 改掉它,respondText 送出去的是一份已經完成的 OutgoingContent,不再做格式協商
errorJson 那個 encodeDefaults = true 不能省,day 12 裝 ContentNegotiation 的時候就是這樣設的,而 ErrorResponse 的 details 有預設值 emptyList(),kotlinx.serialization 預設不會把帶預設值的欄位寫出去,少了那一行,401 的 body 就會停在剛剛量到的那 58 個 byte
status(...) 這個鉤子是 day 15 那篇裝的,它接的是「已經有 status code 但沒有 body 的回應」,兩邊送的都是同一個 ErrorResponse,形狀才會跟其他錯誤一樣,都是 {"status":...,"message":...,"details":[]}
重開 server,bob 那次再打一次
HTTP/1.1 403 Forbidden
X-Request-Id: uyxjgf5m81r8
X-Response-Time: 2ms
WWW-Authenticate: Bearer realm="todo-api", error="insufficient_scope", scope="admin"
Content-Length: 70
Content-Type: application/json
{"status":403,"message":"這個身分不能做這件事","details":[]}
沒帶 token 那次的 401 也跟著多了 details,形狀跟其他錯誤對上了
回到 401 跟 403 的分界,RFC 9110 15.5.4 對 403 的定義是
The 403 (Forbidden) status code indicates that the server understood
the request but refuses to fulfill it. A server that wishes to make
public why the request has been forbidden can describe that reason in
the response content (if any).
If authentication credentials were provided in the request, the
server considers them insufficient to grant access. The client
SHOULD NOT automatically repeat the request with the same
credentials. The client MAY repeat the request with new or different
credentials. However, a request might be forbidden for reasons
unrelated to the credentials.
The client SHOULD NOT automatically repeat the request with the same credentials 是這 2 個狀態碼最實際的差別
bob 那個 403 就是這樣,他的 token 簽章正確、沒過期、token_use 也對,認證那一層完全放行,principal 也確實放上去了,擋他的是 containsAll
而沒帶 token 那個,AuthenticationChecked 一樣會跑,只是 call.principal<TodoUser>() 是 null,plugin 走上面那條分支,log 也留了一行,不過 client 拿到的那個 401 其實不是 plugin 回的,day 27 那個 jwt provider 的 challenge 在 AuthenticationChecked 之前就把回應送出去了,plugin 那個 UnauthorizedResponse 只是空跑,後面 log 那一節會從順序上看到這件事,plugin 那條分支真正回應的場合是 authorize 沒有包在 authenticate 裡面的時候,話說回來,這條分支還是要寫,「沒有身分」跟「身分不夠」不能都算成 403
上面那個 403 帶了一個 WWW-Authenticate
WWW-Authenticate: Bearer realm="todo-api", error="insufficient_scope", scope="admin"
第一眼看起來很怪,day 27 引過 RFC 9110 15.5.2,那條 MUST 是綁在 401 上的,403 沒有這個義務,既然沒有義務,為什麼還要帶
因為 bearer token 這一層另有規定,RFC 6750 3.1 定義了 3 個錯誤碼,其中一個是
insufficient_scope
The request requires higher privileges than provided by the
access token. The resource server SHOULD respond with the HTTP
403 (Forbidden) status code and MAY include the "scope"
attribute with the scope necessary to access the protected
resource.
1 條 SHOULD、1 條 MAY,剛好對上那個 header 的 2 半,error="insufficient_scope" 是 SHOULD 的那一半,scope="admin" 是 MAY 的那一半
scope 那個值是 plugin 裡 required.sorted().joinToString(" ") 產出來的,空白分隔不是隨便挑的,RFC 6750 3 對這個屬性的定義逐字是
The "scope" attribute is defined in Section 3.3 of [RFC6749]. The
"scope" attribute is a space-delimited list of case-sensitive scope
values indicating the required scope of the access token for
accessing the requested resource.
這一輪只要一個角色,所以看到的是 scope="admin"
前面那個 respondFixedJson 要有實測驗證才算數,沒帶 token 打 /todos,請求帶著 Accept: text/xml
curl -i -s -H 'Accept: text/xml' localhost:8080/todos
HTTP/1.1 401 Unauthorized
X-Request-Id: inb7uuw6yuax
X-Response-Time: 0ms
WWW-Authenticate: Bearer realm="todo-api"
Content-Length: 71
Content-Type: application/json
{"status":401,"message":"請帶著有效的 token 再來","details":[]}
跟不帶 Accept 打的那份除了 request id 逐字一樣,status、challenge、content type、71 個 byte 的 body,4 樣都沒有被 Accept 動到
403 那邊同一套,bob 帶著 Accept: text/xml 刪 /todos/3,只看狀態碼跟 content type
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
-X DELETE -H "Authorization: Bearer $BOB" -H 'Accept: text/xml' localhost:8080/todos/3
403 application/json
不是 406,alice 刪同一筆還是 204,這一輪沒有任何一條路被 Accept 改掉答案
要不要把「還缺什麼」告訴 client,這篇跟 day 27 的答案剛好相反,day 27 那 5 種被拒的 token 對外的回應刻意寫成一模一樣,理由是不要幫攻擊者做逐步逼近,403 這裡理由不一樣,這個 client 已經通過認證了,server 知道他是誰,告訴他「這裡需要 admin」並沒有洩漏任何他問不到的東西,反而讓前端有機會把按鈕藏起來或是給一句像樣的提示
authorize(ADMIN_ROLE) 第 1 版是直接包住 delete("{id}"),跟 get、post 平輩,src/main/kotlin/com/cashwu/todo/TodoRoutes.kt 的 route("/todos") 裡面長這樣
authorize(ADMIN_ROLE) {
delete("{id}") {
val id = call.todoId()
if (!repository.delete(id)) {
throw ApiException(HttpStatusCode.NotFound, "找不到 id $id 的待辦")
}
call.respond(HttpStatusCode.NoContent)
}
}
被保護的那條路由行為都對,admin 刪得掉、bob 是 403、沒帶 token 是 401,前面量到的 3 個答案一個都沒變,壞掉的是別的地方,先單獨跑 TodoRoutesTest
TodoRoutesTest > put todos without an id responds method not allowed() FAILED
org.opentest4j.AssertionFailedError: expected: <405 Method Not Allowed> but was: <404 Not Found>
TodoRoutesTest > todos path responds all todos as json() FAILED
org.opentest4j.AssertionFailedError: expected: <[{"id":1,"title":"買牛奶","done":true,"created_at":"2026-08-27T08:00:00Z"},{"id":2,"title":"繳電費","done":false,"created_at":"2026-08-28T09:30:00Z"},{"id":3,"title":"寫 day 05 的文章","done":false,"created_at":"2026-08-29T21:15:00Z"}]> but was: <[{"id":1,"title":"買牛奶","done":true,"created_at":"2026-08-27T08:00:00Z"},{"id":2,"title":"繳電費","done":false,"created_at":"2026-08-28T09:30:00Z"},{"id":3,"title":"寫 day 05 的文章","done":false,"created_at":"2026-08-29T21:15:00Z"},{"id":4,"title":"倒垃圾","done":false,"created_at":"2026-09-04T07:04:32.186873Z"}]>
23 tests completed, 2 failed
2 個要分開看,第 1 個是真的,PUT /todos 沒有對應的 handler,/todos 這條路徑上有 get 跟 post,所以正確答案是 405 Method Not Allowed,加了一個 authorize 之後它變成 404,這個斷言不是這篇寫的,它從 day 06 就在,day 26 又在 AuthenticationTest 補了一個同樣意思的,整包跑的時候那一個也一起倒
第 2 個不是路由的事,多出來的 id 4 是同一個 class 前面某個 POST /todos 留下來的,就是前面那個共用 H2 資料庫名字的老問題,路由一改,測試的執行時序跟著變,連線池關掉的時機也跟著變,上一個測試的資料就留給了下一個,整包跑會多出 10 幾個這種連鎖失敗,而且每次倒的不是同一批,要找的是「每次都倒、訊息還逐字一樣」的那幾行,這一輪就只有 405 變 404 那 2 行
把問題縮到最小,4 個 route 形狀各寫成一個測試,4 個都只問同一件事,PUT /x 回什麼,測試放在新的 src/test/kotlin/com/cashwu/todo/AuthorizationTest.kt,用的是前面那個 probeApplication,handler 全部留空,因為要量的是 routing 的解析結果,不是 handler 做了什麼,4 個形狀只差包裝那一層,所以先抽一個 helper,/x 底下固定有一個 get,要量的那一段由每個測試自己接
private fun ApplicationTestBuilder.shapeApplication(block: Route.() -> Unit) {
probeApplication {
routing {
route("/x") {
get { }
block()
}
}
}
}
第 1 個是沒有任何包裝的基準,第 2 個把 authorize 包在 delete("{id}") 外面,跟 get 平輩,也就是第 1 版的形狀,第 3 個把 authorize 換成 Ktor 自己的 authenticate,其他一個字都不動,第 4 個把包裝往下推一層,路徑那一段先有自己的節點
@Test
fun `without the wrapper the same shape answers 405`() = testApplication {
shapeApplication { delete("{id}") { } }
assertEquals(HttpStatusCode.MethodNotAllowed, client.put("/x").status)
}
@Test
fun `a transparent wrapper under the path node turns a 405 into a 404`() = testApplication {
shapeApplication { authorize(ADMIN_ROLE) { delete("{id}") { } } }
assertEquals(HttpStatusCode.NotFound, client.put("/x").status)
}
@Test
fun `the same thing happens with the authenticate that ktor ships`() = testApplication {
shapeApplication { authenticate(TODO_AUTH) { delete("{id}") { } } }
assertEquals(HttpStatusCode.NotFound, client.put("/x").status)
}
@Test
fun `moving the wrapper one level deeper brings the 405 back`() = testApplication {
shapeApplication { route("{id}") { authorize(ADMIN_ROLE) { delete { } } } }
assertEquals(HttpStatusCode.MethodNotAllowed, client.put("/x").status)
}
405、404、404、405,第 3 個是關鍵,那不是我們寫的 plugin,那是 Ktor 自己的 authenticate,行為跟第 2 個一模一樣,所以這不是自製 plugin 的 bug,前面那段 Transparent 的說明在這裡對上了,透明的 route 節點直接掛在路徑節點底下的時候,routing 解析失敗的原因會從「方法不對」變成「路徑不對」
第 4 個是解法,把 authorize 往下推一層,包在 route("{id}") 裡面而不是跟 get、post 當兄弟,405 就回來了,回到 TodoRoutes.kt,第 2 版就是這個形狀
route("{id}") {
authorize(ADMIN_ROLE) {
delete {
val id = call.todoId()
if (!repository.delete(id)) {
throw ApiException(HttpStatusCode.NotFound, "找不到 id $id 的待辦")
}
call.respond(HttpStatusCode.NoContent)
}
}
}
跟第 1 版比,路徑那一段從 delete("{id}") 的參數變成一層自己的 route("{id}"),authorize 搬進去,delete 不再帶路徑,這就是前面那個多包一層的理由
這件事的殺傷力在於它沒有直接的錯誤訊息,加一個 authorize 上去,被保護的那條路由行為完全正確,第 1 個壞掉的是隔壁一條不相干的路由的錯誤碼,而 404 跟 405 都是 4xx,光看 client 的行為不會發現,那些測試都是在那之前寫的,要不是它們,這件事會一路上線
所以這篇在同一個 AuthorizationTest.kt 裡多留一個測試,這個不是最小形狀,打的是真實的 API
@Test
fun `the todo api keeps answering 405 for a put without an id`() = testApplication {
val client = todoApplication()
val response = client.put("/todos")
assertEquals(HttpStatusCode.MethodNotAllowed, response.status)
assertNull(response.headers[HttpHeaders.WWWAuthenticate])
}
todoApplication() 是 TestApp.kt 裡那個把整個 app 起起來、回一個已經帶著 TEST_TOKEN 的 client 的 helper,第 2 個斷言盯的是另一件事,405 不能是一個認證挑戰,這條路上根本還沒走到身分那一層
authorize 需要 principal,principal 是 authenticate 放的,所以它得疊在 authenticate 裡面
如果沒有疊呢,原本猜的是「安靜地放行」,因為 plugin 讀不到東西,最省事的行為就是什麼都不做,實測不是這樣,測試一樣放在 src/test/kotlin/com/cashwu/todo/AuthorizationTest.kt
@Test
fun `authorize outside authenticate rejects everyone`() = testApplication {
probeApplication {
routing {
authorize(ADMIN_ROLE) {
get("/orphan") { call.respondText("reached") }
}
}
}
assertEquals(HttpStatusCode.Unauthorized, client.get("/orphan").status)
assertEquals(
HttpStatusCode.Unauthorized,
bearerClient(testIssuer.accessToken(ALICE)).get("/orphan").status,
)
}
probeApplication 就是前面那個 helper,預設的 module 關掉,只裝一個 Authentication,routing 這個測試自己接
2 個斷言都是 401,包括帶著 alice 那張有 admin 的有效 token,因為那條路上沒有人跑認證,principal 從來沒被放上去,plugin 讀到 null,走的是 401 那條分支
方向是對的,這是 fail closed,寫錯的結果是全部擋住而不是全部放行,但錯誤訊息會騙人,回的是「請帶著有效的 token 再來」,而 token 明明是有效的,真的踩到的時候,要去看的是 routing 的巢狀結構,不是 token
對外 401 跟 403 已經分開了,對內還要能分出是哪一層擋的,Authorization.kt 裡那個 KtorSimpleLogger("com.cashwu.todo.authorization") 就是為了這個,src/main/resources/logback.xml 把它開到 DEBUG
<logger name="io.ktor.auth.jwt" level="DEBUG"/>
+ <logger name="com.cashwu.todo.authorization" level="DEBUG"/>
上面那一行是 day 27 加的,前面那 2 次登入加 3 個 DELETE,把 access log 跟 DEBUG 混在一起看,這一輪逐字是這樣,logback 的 pattern 是 %logger{20},所以 com.cashwu.todo.authorization 被縮寫成 c.c.t.authorization
16:10:01.811 INFO [+xu9urt0/cqz] Application -- 200 POST /login 22ms
16:10:01.824 INFO [7cr5wd0benhm] Application -- 200 POST /login 1ms
16:10:01.886 DEBUG [2wjtwyl+hl08] c.c.t.authorization -- Authorization failed: bob has [user] but needs [admin]
16:10:01.888 INFO [2wjtwyl+hl08] Application -- 403 DELETE /todos/1 12ms
16:10:01.916 INFO [f8wlkoqm7l42] Application -- 204 DELETE /todos/1 18ms
16:10:01.932 DEBUG [sttsmk053tw2] io.ktor.auth.jwt -- JWT authentication failed: No credentials provided
16:10:01.933 INFO [sttsmk053tw2] Application -- 401 DELETE /todos/2 2ms
16:10:01.933 DEBUG [sttsmk053tw2] c.c.t.authorization -- Authorization failed: no principal, 1 role(s) required
bob 那一筆只有 c.c.t.authorization 一行,訊息裡直接寫出他有什麼、缺什麼,因為他的認證是通過的,alice 那一筆一行 DEBUG 都沒有,兩關都過,沒帶 token 那一筆 2 層各留一行,sttsmk053tw2 這個 call id 出現 3 次
使用者回報「我一直被擋」的時候,拿他的 request id 進來查,看到幾行、看到哪幾層,就知道要去改的是他的角色還是他的 token
順序還說了一件前面提過的事,沒帶 token 那一筆,401 DELETE /todos/2 這行 access log 排在授權那行 DEBUG 前面,2 行的毫秒剛好一樣,看的是寫進 log 的先後,access log 是在回應送出去的時候寫的,所以回應在授權的分支跑到之前就已經出去了,client 拿到的 401 是 jwt provider 的 challenge 回的,plugin 那條 null 分支照樣執行、照樣寫 log,只是它的 UnauthorizedResponse 沒有機會上線
這是 day 27 那套 logger 的延續,訊息裡一樣沒有 token,角色跟名字有進 log,這是想過才留的,那 2 個東西 log 本來就會有,sub 就寫在 access log 對得上的那個請求裡
測試都在前面各節出現過了,這一節只補沒單獨拿出來講的那幾個
新的測試檔案只有一個,src/test/kotlin/com/cashwu/todo/AuthorizationTest.kt,從「hook 選錯的代價」那節開了頭,一路加到這裡,既有的檔案動到 4 個,ConfigurationTest、TestApp.kt,還有 AuthenticationTest 跟 JwtTest 那 2 個 /me 的斷言,都是「先讓測試編得起來」那節走過的
前面用 curl 量過的那幾件事各有一個對應的測試,同一條 DELETE /todos/1 3 種身分的 204、403、401,403 的 body 跟 WWW-Authenticate 逐字比對,還有帶著 Accept: text/xml 打 401 跟 403 都不會變成 406,這些跟 curl 看到的是同一件事,就不再貼一次
值得單獨講的是下面這幾個
第 1 個是「被擋的只有那一條」
@Test
fun `a user without the role still gets the rest of the api`() = testApplication {
todoApplication()
val bob = bearerClient(testIssuer.accessToken(BOB))
assertEquals(HttpStatusCode.OK, bob.get("/todos").status)
assertEquals(
HttpStatusCode.Created,
bob.post("/todos") {
contentType(ContentType.Application.Json)
setBody("""{"title":"倒垃圾"}""")
}.status,
)
assertEquals(
HttpStatusCode.OK,
bob.put("/todos/1") {
contentType(ContentType.Application.Json)
setBody("""{"title":"買豆漿"}""")
}.status,
)
}
授權出錯的方式有 2 種,該擋的沒擋,跟不該擋的擋了,前面那些測試盯的是第 1 種,這個盯的是第 2 種
第 2 個是 claim 本身跟預設值,roles 進 token 之後長什麼樣
@Test
fun `the roles ride in the token as a sorted array`() {
val decoded = JWT.decode(testIssuer.accessToken(ALICE))
assertEquals(listOf("admin", "user"), decoded.getClaim(ROLES_CLAIM).asList(String::class.java))
}
這個測試連 testApplication 都不用,簽一張票直接解開看,斷言是 listOf 而不是 setOf,順序也在斷言範圍裡,sorted() 那一行拿掉這個測試就不穩
一張完全沒有角色的 token 呢
@Test
fun `a token with no roles at all cannot delete`() = testApplication {
todoApplication()
val nobody = bearerClient(testIssuer.accessToken(TodoUser("alice")))
assertEquals(HttpStatusCode.OK, nobody.get("/todos").status)
assertEquals(HttpStatusCode.Forbidden, nobody.delete("/todos/1").status)
}
TodoUser("alice") 用的就是那個預設值 emptySet(),簽出來的 token 沒有任何角色,2 個斷言合起來是「進得來,但刪不了」,也就是升級之後舊的 token 不會壞掉、也不會意外拿到權限
第 3 個是沒有角色要求的 authorize
@Test
fun `authorize with no roles lets any signed in user through`() = testApplication {
probeApplication {
routing {
authenticate(TODO_AUTH) {
authorize {
get("/anyone") { call.respondText(call.principal<TodoUser>()!!.name) }
}
}
}
}
assertEquals("bob", bearerClient(testIssuer.accessToken(BOB)).get("/anyone").bodyAsText())
assertEquals(HttpStatusCode.Unauthorized, client.get("/anyone").status)
}
authorize { } 沒帶參數的時候 required 是空集合,containsAll(emptySet()) 永遠是 true,所以任何一個有身分的人都過得去,沒有身分的還是 401,這是 containsAll 的自然結果,寫成測試是因為它是被依賴的行為,不是巧合
第 4 個是 /refresh 那個 bug 的守門人
@Test
fun `refresh reads the roles from the directory again`() = testApplication {
todoApplication()
val first = Json.decodeFromString<TokenResponse>(
client.post("/login") {
contentType(ContentType.Application.Json)
setBody("""{"name":"alice","password":"test-alice-secret"}""")
}.bodyAsText()
)
val refreshed = Json.decodeFromString<TokenResponse>(
client.post("/refresh") {
contentType(ContentType.Application.Json)
setBody("""{"refreshToken":"${first.refreshToken}"}""")
}.bodyAsText()
)
assertEquals(
listOf("admin", "user"),
JWT.decode(refreshed.accessToken).getClaim(ROLES_CLAIM).asList(String::class.java),
)
}
登入拿一組、拿 refresh token 換一組、把新的 access token 解開看角色還在不在,這個斷言擋的是「有人為了少一次查詢,把 /refresh 改回抄舊 token 的 roles」
Relix day 23 那篇一次做完認證跟授權,這篇是 Ktor 這邊補上授權那一半,所以對得上的地方特別多
第 1 個對照點是「Ktor 沒有內建授權」,這篇確認了,ktor-server-auth 裡有 ForbiddenResponse 這種零件,可是沒有任何 authorize,零件跟決策的分界線就在這裡
第 2 個對照點是 authorize 要不要包含 authenticate,Relix 的實作是包含的,requiresAuth = true 一起設定,使用者不用把 2 層疊起來寫,這篇的不包含,authorize 必須疊在 authenticate 裡面,代價就是前面那個「放外面所有人都是 401」的行為,差別的來源是誰在管認證,Relix 的路由表是自己的,改一個 flag 就好,這篇的 authenticate 是 Ktor 的,要在 authorize 裡面自動幫你套上,等於自製 plugin 要決定套哪一個具名 provider,那件事應用自己知道,plugin 不知道
第 3 個對照點是多角色的語意,Relix 是 OR,這篇的 containsAll 是 AND,這一點連帶影響巢狀,Relix 那篇因為是 OR,巢狀時讓內層優先,不跟外層做聯集,不然 authorize("admin") { authorize("editor") { } } 會變成 admin 或 editor 都能進,巢狀反而放寬了限制,2 種都合理,但一定要講清楚是哪一種,因為 authorize("a", "b") 讀起來 2 種都像
第 4 個對照點是 401 跟 403 的分工,Relix 那篇那張表的前 2 欄這篇完全同意,沒認證是 401、已認證但權限不足是 403,最後一欄不一樣,那篇寫 403「無特殊 header」,這篇的 403 有 WWW-Authenticate,這不是誰寫錯了,那張表講的是 HTTP 通用的認證框架,通用框架確實沒有規定 403 要帶什麼,這篇多了一層 OAuth 2.0 bearer token 的語意,insufficient_scope 是那一層額外定義的,兩邊不衝突,是不同的抽象層各說各的那一層
第 5 個對照點這篇同意但沒做完,Relix 那篇把線畫在「角色進 middleware,資源層級的 ownership 留在 handler」,這篇只做到角色那一半,todo-api 的 todos 表連 owner 欄位都沒有,所以現在 bob 可以改 alice 的待辦,authorize 幫不上忙,那是資料模型的事
最後一個是字串角色的取捨,Relix 那篇就提過 authorize("admni") 這種 typo compiler 攔不住,要到 runtime 才會發現所有人都被擋在外面,解法是 enum 或 sealed class,代價是框架得先知道應用有哪些角色,這篇的 ADMIN_ROLE 是一個常數,比裸字串好一點,但 authorize(vararg roles: String) 的角色參數還是 String,寫錯照樣編得過,這是這篇留下的債
授權的入口是 principal,而 principal 是認證放上去的,所以鉤子選錯就什麼都讀不到,onCall 掛在 Plugins phase,比認證早,讀到的是 null,正確的是 ktor-server-auth 的 on(AuthenticationChecked),它自己帶一個插在 Validators 後面的 phase
Ktor 沒有內建授權,ktor-server-auth 給的是 ForbiddenResponse 這種零件而不是決策,authorize 要自己寫,角色從 application.yaml 進來、簽進 roles claim,sorted() 是為了讓 token 的內容穩定
這篇修掉 day 27 的一個真 bug,/refresh 只抄 payload.subject 就重簽,角色全部掉光,修法是拿 sub 回名單重查而不是抄舊 token 的 roles,因為 token 裡的角色是簽發那一刻的快照,被降權的人不該靠著手上那張還有 7 天的 refresh token 一直換回 admin
401 跟 403 的分界依據是 RFC 9110 §15.5.4,401 是換張票再來、403 是重試也沒用,403 那個 WWW-Authenticate 不是多餘的,RFC 6750 §3.1 裡 error 是 SHOULD、scope 是 MAY,2 個 status 的 body 都走 respondFixedJson,不經過 ContentNegotiation,所以 Accept 換不掉它們
2 個坑都跟巢狀有關,authorize 放錯一層會把隔壁的 405 變成 404,Ktor 自己的 authenticate 也一樣,所以那不是自製 plugin 的 bug,是透明的 route 節點直接掛在路徑節點底下時,routing 解析的失敗原因從方法不對變成路徑不對,反過來,authorize 沒有包在 authenticate 裡面的時候不是安靜放行,是所有人都 401,方向上是 fail closed,但錯誤訊息會騙人
這篇留下的問題要講清楚,角色是字串,typo 編譯器攔不住,使用者跟角色都還在 application.yaml 裡,密碼還是明文,這件事從 day 27 到現在,最大的一筆是 ownership,todos 表連 owner 欄位都沒有,bob 現在還是可以改 alice 的待辦,那不是 authorize 修得掉的,還有 containsAll 那個 AND,巢狀的 authorize 要怎麼算,這篇沒有定義也沒有測,另外 authorize 的 realm 是寫死的預設值,AUTH_REALM 一被環境變數覆寫,401 跟 403 就會說自己屬於不同的 realm
下一篇換一個方向,到這裡 todo-api 有 CRUD、有驗證、有錯誤格式、有認證也有授權,可是一個新來的人要怎麼知道 DELETE /todos/{id} 需要 admin、POST /todos 的 body 長什麼樣,day 29 做 OpenAPI 跟 Swagger,把這些規格從程式碼裡拿出來變成一份看得到、點得動的文件
同步刊登於 Blog
圖片來源:AI 產生