Если вы любите интегрироваться с внешними системами, которые придерживаются высоких стандартов безопасности, то наверняка сталкивались с такой штукой как 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
Исходники можно взять здесь. Удачи!