Если вы любите интегрироваться с внешними системами, которые придерживаются высоких стандартов безопасности, то наверняка сталкивались с такой штукой как Mutual TLS (aka взаимная TLS аутентификация или mTLS). Ничего особенно сложного в такой интеграции нет. За исключением двух нюансов:
- Интеграцию придется делать в каждом сервисе
- В каждый сервис нужно положить клиентские сертификаты для прохожения хэндшейка mTLS
И если с первым нюансом можно как-то жить и мириться, то второй уже вызывает гораздо больше проблем и приводит к грустным лицам вашей команды инфобеза. Чтобы сделать их снова счастливыми, а заодно ликвидировать потребность писать один и тот же код в каждом сервисе — можно сделать умный reverse proxy, который возьмет на себя все тяготы и лишения связанные с хэндшейком и возможно какой-то другой дополнительной логикой, которую накрутили авторы внешней системы. Делать это хозяйство будем на Ktor, потому что почему бы и нет ;)
Предварительные ласки
Чтобы избежать любых неожиданностей, сразу обозначу стек технологий на котором будем все делать:
- Ktor + netty в качестве клея для всего
- okhttp как движок для Ktor-овского http клиента
- okhttp-tls для операций с сертификатами
- MockServer будет выступать в роли внешней, сверхзащищенной системы
Также предполагается, что у вас уже установлен Docker. Все описываемое далее можно сделать и без него, но с ним будет проще.
Настройка MockServer-а
Для того чтобы все заработало, нужно сгенерировать клиентский сертификат, про который будет знать сервер и с которым в него будет ходить клиент. Проще всего это сделать из консоли с помощью openssl:
|
|
Если все прошло успешно, то должно появиться два файла:
client-cert.crt
— сертификатclient-cert.key
— секретный ключ
Теперь все готово к запуску MockServer с поддержкой mTLS аутентификации. Будьте внимательны с указанием путей к клиентским сертификатам на вашей машине.
$ docker pull mockserver/mockserver
$ docker run -d --rm \
-e MOCKSERVER_TLS_MUTUAL_AUTHENTICATION_REQUIRED=true \
-e MOCKSERVER_TLS_MUTUAL_AUTHENTICATION_CERTIFICATE_CHAIN=/opt/client-cert.crt \
-v $(pwd)/client-cert.crt:/opt/client-cert.crt \
-P mockserver/mockserver
Если сейчас просто дернуть GET /
, то ничего хорошего из этого не выйдет, т.к. наш сервер будет ожидать хэндшейка с
клиентскими сертификатами.
|
|
Но просто так передать сертификат и ключ не выйдет. Нужен еще CA сертификат, который нужно встроить в “цепочку доверия” при выполнении запроса. MockServer использует свой собственный CA сертификат, который нужно скачать и добавить к запросу.
|
|
Отлично, хэндшейк прошел успешно. Осталось заставить сервер возвращать что-то более осознанное чем 404 Not Found
.
|
|
Подробности о произошедшем выше можно взять в официальной документации к MockServer.
Настройка проекта
После создания свежего Ktor-проекта в него нужно добавить http клиент и okhttp.
gradle.properties
ktor_version=1.6.2
okhttp_version=4.9.1
build.gradle.kts
val ktor_version: String by project
val okhttp_version: String by project
dependencies {
implementation("io.ktor:ktor-client-core:$ktor_version")
implementation("io.ktor:ktor-client-okhttp:$ktor_version")
implementation("com.squareup.okhttp3:okhttp-tls:$okhttp_version")
}
И набросать каркас проекта в файле Application.kt
:
fun main(args: Array<String>) = EngineMain.main(args)
@Suppress("unused")
fun Application.module() {
//TODO: Преобразовать сертификаты
//TODO: Подготовить сертификаты для хэндшейка
//TODO: Настроить http клиент на работу с клиентскими сертификатами
//TODO: Реализовать проксирование запросов во внешнюю систему
}
Подготовка сертификатов
Теперь начинается самое интересное. Нам нужно преобразовать сертификаты в java-объекты, которые понимает http клиент.
В настоящих системах передавать сертификаты сервису необходимо из переменных окружений. Во-первых это более правильно с точки зрения Maintainability, а во-вторых уменьшает шанс утечки сертификатов через код. В идеале разработчики такого proxy вообще не должны иметь доступа к клиентским production-сертификатам.
Начнем с преобразования CA сертификата. В нашем случае он один, но часто их бывает несколько и они расположены друг за другом в одном файле:
-----BEGIN CERTIFICATE-----
<содержимое сертификата>
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
<содержимое сертификата>
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
<содержимое сертификата>
-----END CERTIFICATE-----
Поэтому лучше сразу сделать метод, который распарсит такой файл и преобразует его в массив объектов X509Certificate .
private val PEM_REGEX = Regex("""-----BEGIN ([!-,.-~ ]*)-----([^-]*)-----END \1-----""")
fun String.splitCertificatesPem(): Sequence<X509Certificate> {
return PEM_REGEX.findAll(this).map { match ->
match.groups[0]!!.value.decodeCertificatePem()
}
}
С утилитами разобрались. Теперь сделаем три константы, которые будут хранить наши сертификаты в текстовом виде. Это сделано для простоты учебного примера и в реальных системах так делать нельзя. Потом преобразуем эти константы в сертификаты и создадим специальный объект для хэндшейка.
private const val CACERT = "-----BEGIN CERTIFICATE-----\n" +
"MIIDqDCCApCgAwIBAgIEPhwe6TANBgkqhkiG9w0BAQsFADBiMRswGQYDVQQDDBJ3\n" +
...
"Gm5MBedhPkXrLWmwuoMJd7tzARRHHT6PBH/ZGw==\n" +
"-----END CERTIFICATE-----\n"
private const val CLIENT_CERT = "-----BEGIN CERTIFICATE-----\n" +
"MIIFgjCCA2oCCQCeHxaTaEwG6TANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMC\n" +
...
"HGh6/2TZ+8X8OeUlJ7DGIATIb8smzQ==\n" +
"-----END CERTIFICATE-----\n"
private const val CLIENT_KEY = "-----BEGIN PRIVATE KEY-----\n" +
"MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC4lUH0MAdJkfUI\n" +
...
"7jrTiwcEtANdV45Zd4mAS6ctvNIJvg==\n" +
"-----END PRIVATE KEY-----\n"
val caBundle = CACERT.splitCertificatesPem()
val heldCertificate = HeldCertificate.decode(CLIENT_CERT + CLIENT_KEY)
val handshakeCerts = HandshakeCertificates.Builder().apply {
caBundle.forEach { addTrustedCertificate(it) }
heldCertificate(heldCertificate)
}.build()
Настройка http клиента
Чтобы http клиент мог использовать наши сертификаты нужно настроить sslSocketFactory
, которая будет цеплять нужные
сертификаты ко всем запросам во внешнюю систему обеспечивая тем самым взаимную аутентификацию по TLS.
val ktorClient = HttpClient(OkHttp) {
expectSuccess = false
engine {
config {
sslSocketFactory(handshakeCerts.sslSocketFactory(), handshakeCerts.trustManager)
}
}
}
По умолчанию Ktor клиент будет кидать исключение, если получил в ответ
что-то отличное от 2xx
. В нашем случае это поведение крайне нежелательно, поэтому нужно выключить эту штуку
установив expectSuccess = false
. При желании можно еще добавить логирование запросов. Пусть это будет вашим домашним
заданием.
Проксирование запросов
Осталось сделать самую малость: перенаправить запрос пришедший от клиента во внешнюю систему. Чтобы все было еще интереснее, предположим, что эта система помимо mTLS аутентификации также требует ключ для доступа к своему API. А мы, поскольку пишем умный прокси, возьмем на себя добавление этого ключа к запросам.
intercept(ApplicationCallPipeline.Call) {
val response = ktorClient.request<HttpResponse>("https://localhost:55000${call.request.uri}") {
method = call.request.httpMethod
headers {
append("X-API-KEY", "102CAE97-17F9-4B08-82C0-AB7653F2EC1B")
}
if (call.request.httpMethod != HttpMethod.Get) {
body = call.receiveText()
}
}
call.respond(object : OutgoingContent.WriteChannelContent() {
override val status: HttpStatusCode = response.status
override suspend fun writeTo(channel: ByteWriteChannel) {
response.content.copyAndClose(channel)
}
})
}
Показанный вариант довольно простой и его точно придется доработать под ваши нужды, но суть останется той же самой.
Нужно перехватить вызов, который пришел от клиента, добавить туда все недостающие компоненты, послать запрос во внешнюю
систему и вернуть клиенту полученный ответ. Важным здесь является установка такого же кода ответа, каким ответила
внешняя система. Если этого не сделать, то даже при получении ошибок 4xx
и 5xx
наш прокси будет радостно возвращать
200 OK
. Также нужно не забыть установить именно тот порт, на котором крутится MockServer. В примере это 55000
, но у
вас наверняка будет другой.
Проверка работоспособности
На текущий момент, если вы все сделали правильно и у вас ничего не упало при запуске, то можно начинать стрелять запросы в наш прокси чтобы убедиться, что все работает. Теперь к запросом можно не добавлять никакие сертификаты, прокси все будет делать за нас.
$ curl -GET http://localhost:8080/pets
[ {
"id" : 0,
"name" : "some_string_value",
"tag" : "some_string_value"
} ]%
$ curl -v -POST -H "Content-Type: application/json" \
-d '{"id": 1, "name": "test", "tag": "test-tag"}' \
http://localhost:8080/pets
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /pets HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 44
>
* upload completely sent off: 44 out of 44 bytes
< HTTP/1.1 201 Created
< transfer-encoding: chunked
<
* Connection #0 to host localhost left intact
* Closing connection 0
Исходники можно взять здесь. Удачи!