The English version of this article is available on Medium.
Ktor 是一个用于轻松构建异步服务器端和客户端应用程序的框架,使用 Kotlin 编写。它支持许多引擎(服务端/客户端),包括 Ktor 团队开发的 CIO
(基于协程的 I/O)引擎。
今天我们要在 Ktor 上实现一个可用的反向代理,用来保护它背后的 Keycloak 服务器。鼓捣了半天才发现有示例,不过没关系,用这个示例代理 Keycloak 还是不太行的。
了解 Ktor 的部分我就不过多赘述了,直接开始敲代码。
依赖项
依赖项其实很简单,一个服务端引擎和一个客户端引擎即可,我们在这里使用 CIO。
# Gradle version catalog
[versions]
ktor = "3.0.0-beta-1"
[libraries]
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
[plugins]
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
主应用程序(Application)和模块(module)
主应用程序可以选择 embeddedServer
或 EngineMain
,可根据项目情况进行配置。
我们打算不在主模块中添加过多内容,而是把它们拆分到单独的文件中。简单的主模块:
fun Application.module() {
routing {
proxyKeycloak()
}
}
反向代理
这回进入正题了。反向代理,说白了就是一台服务器接收到请求,转发到目标服务器,接收到目标服务器的响应后,再转发回客户端。所以我们的应用中需要先配置一个客户端。
客户端
// proxy/Keycloak.kt
private val keycloakProxyClient = HttpClient()
非常简洁的一个客户端。请注意,如果你向项目中添加了多个客户端引擎,需要显式指定。例子如下:
HttpClient(CIO)
Keycloak
这时我们指定两个主机名:ktor
(端口 8080)和 keycloak
(端口 8081)。
我们假定 Keycloak 托管在路径 /auth
下,需要对 Keycloak 进行如下配置(红帽的中文版文档)。
http-enabled=true # 对进入 Keycloak 的请求启用 HTTP
http-port=8081
proxy=edge
http-relative-path=/auth
hostname-strict=false
代理
配置完毕,就可以开始编写 proxyKeycloak()
了。
// proxy/Keycloak.kt
fun Route.proxyKeycloak() {
route(Regex("/auth|/auth/.*")) {
// TODO
}
}
我们使用 route
DSL 指定了一个路径,告诉 Ktor 匹配这个正则表达式的请求交给这个 lambda 处理。Ktor 提供了 get
和 post
扩展函数,但是我们需要代理所有请求,故不使用它们。
接下来是处理请求和发送新请求。
// proxy/Keycloak.kt
@OptIn(InternalAPI::class)
fun Route.proxyKeycloak() {
route(Regex("/auth|/auth/.*")) {
handle {
keycloakProxyClient.request {
url("http://$keycloakHost:$keycloakPort${call.request.uri}")
method = call.request.httpMethod
headers.appendAll(call.request.headers) // 哈?
// 嗯?
body = call.receive()
}.let { response ->
call.respond(object : OutgoingContent.WriteChannelContent() {
override val contentLength: Long? = response.contentLength()
override val contentType: ContentType? = response.contentType()
override val status: HttpStatusCode = response.status
override val headers: Headers = Headers.build {
appendAll(response.headers) // 啊?
}
override suspend fun writeTo(channel: ByteWriteChannel) {
response.content.copyAndClose(channel)
}
})
}
}
}
}
到这里好像就大功告成了,但很显然是不可以的,因为我们还没有配置 Forwarded
标头(也可以用 X-
非标标头,不过既然标准的摆在这,就用了吧)。
标头
在 嗯?
处插入:
headers[HttpHeaders.Forwarded] = "for=${call.request.local.remoteHost};host=${call.request.headers[HttpHeaders.Host] ?: ""};proto=${call.request.local.scheme}"
这样我们就可以告诉 Keycloak,发出请求的真实地址是什么。
不过,现在我们还是不能让代理服务器变得可用,因为 Ktor 管理了一些标头,并且不允许手动指定,否则会引发异常,造成 500 错误。让我们排除这些标头。
val Headers.safeExplicitness get() = filter { key, _ ->
!key.equals(
HttpHeaders.TransferEncoding, ignoreCase = true
) && !key.equals(
HttpHeaders.ContentType, ignoreCase = true
) && !key.equals(
HttpHeaders.ContentLength, ignoreCase = true
)
}
将 啊?
处修改为:
appendAll(response.headers.safeExplicitness)
那 哈?
怎么办呢?如果对它也应用 safeExplicitness
,会导致发送给 Keycloak 的信息不正确:
{"error":"invalid_request","error_description":"Missing form parameter: grant_type"}
打开浏览器的开发者工具会发现,grant_type
其实已经被指定了,于是乎,哈?
处只能修改为:
headers.appendAll(call.request.headers)
headers.remove(HttpHeaders.TransferEncoding)
headers[HttpHeaders.Host] = "$keycloakHost:$keycloakPort" // 还需要加上真实的 Host 标头
这回访问 Keycloak 不会报异常了。
不要重定向
这也是一个坑点,HttpClient
默认跟随重定向,这会导致发送给 Keycloak 的重定向 URI 出现循环(还记得我们前面提过的主机名吗,不记得的话,先回去看一下再浏览下面的日志):
WARN [org.keycloak.events] (executor-thread-123) type="LOGIN_ERROR", ***, error="invalid_redirect_uri", redirect_uri="http://ktor/***/auth?***&redirect_uri=http%3A%2F%2Fktor%2F***%2F&state=***&response_mode=fragment&response_type=code&scope=openid&nonce=***&prompt=none&code_challenge=***&code_challenge_method=***"
发现了吗,它会把我们重定向到一个新的认证请求上。当然,应用默认策略不允许这样的重定向 URI,于是这样的请求会被屏蔽,导致无法登录。
解决方法也很简单:
private val keycloakProxyClient = HttpClient {
followRedirects = false
}
这样,我们的反向代理的 Keycloak 就完全可用了。
保护一下
还要避免公开一些不该公开的东西,把路径的表达式替换为 /auth/((js|realms|resources)/.*|robots.txt|favicon.ico)
即可。