Как проверить, что запрос поступает от Twilio на облачной платформе Google с http4k?
У меня есть сервер, использующий Kotlin 1.5, JDK 11, http4k v4.12, и у меня есть Twilio Java SDK v8.19, размещенный с использованием Google Cloud Run.
Я создал предикат с помощью Java SDK от Twilio
RequestValidator
.
import com.twilio.security.RequestValidator
import mu.KotlinLogging
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Method
import org.http4k.core.Response
import org.http4k.core.Status
import org.http4k.core.body.form
import org.http4k.core.queries
import org.http4k.core.then
import org.http4k.core.toParametersMap
import org.http4k.filter.RequestPredicate
import org.http4k.filter.ServerFilters
import org.http4k.lens.Header
private val twilioAuthHeaderLens = Header.optional("X-Twilio-Signature")
/** Twilio's helper [RequestValidator]. */
private val twilioValidator = RequestValidator("my-auth-token")
/**
* Use the Twilio helper validator, [RequestValidator]
*/
val twilioAuthPredicate: RequestPredicate = { request ->
when (val requestSignature: String? = twilioAuthHeaderLens(request)) {
null -> {
logger.debug { "Request has no Twilio request header valid" }
false
}
else -> {
val uri: String = request.uri.toString()
val paramMap: Map<String, String?> = request.form().toMap()
logger.info { "Validating request with uri: $uri, paramMap: $paramMap, signature: $requestSignature" }
val isTwilioSignatureValid = twilioValidator.validate(uri, paramMap, requestSignature)
logger.info { "Request Twilio valid: $isTwilioSignatureValid" }
isTwilioSignatureValid
}
}
}
Это работает на примере, предоставленном Twilio, как показано на этом модульном тесте Kotest.
(тест и код примера не совпадают, но
OperatorAuth
это класс, который применяет
twilioAuthPredicate
, а также
ApplicationProperties
извлекает ключ аутентификации Twilio из файла .env.)
test("demo https://www.twilio.com/docs/usage/security") {
val twilioApiKey = "12345"
val appProps = ApplicationProperties(
TWILIO_API_AUTH_TOKEN(twilioApiKey, TEST_ENV)
)
// system-under-test
val handler: HttpHandler = OperatorAuth(appProps).then { Response(OK) }
// construct a GET request: https://mycompany.com/myapp.php?foo=1&bar=2
val urlProto = "https"
val urlBase = "mycompany.com"
val requestSignature = "0/KCTR6DLpKmkAf8muzZqo1nDgQ="
val request = Request(Method.GET, "$urlProto://$urlBase/myapp.php")
.query("foo", "1")
.query("bar", "2")
.form("CallSid", "CA1234567890ABCDE")
.form("Caller", "+12349013030")
.form("Digits", "1234")
.form("From", "+12349013030")
.form("To", "+18005551212")
.header("X-Twilio-Signature", requestSignature)
.header("X-Forwarded-Proto", urlProto)
.header("Host", urlBase)
val response = handler(request)
response shouldHaveStatus OK
}
Однако, за исключением этого простого примера, никакие другие запросы не работают ни при создании модульного теста, ни в режиме реального времени. Все запросы Twilio не проходят проверку, и мой сервер возвращает 401. Информация на веб-сайте Twilio полностью непрозрачна. Это невероятно расстраивает. Он не говорит мне, как вычисляется хэш, поэтому я не могу сказать, что идет не так.
Warning 15003
Message Got HTTP 401 response to https://my-gcr-server.run.app/twilio
Вот пример теста с использованием реальных значений, собранных из журнала (хотя я редактировал идентификаторы).
test("real request") {
val appProps = ApplicationProperties() // this loads the Twilio Auth Key from my environment variables
val handler: HttpHandler = OperatorAuth(appProps).then { Response(OK) }
// construct a GET request
val urlProto = "https"
val urlBase = "my-gcr-server.run.app"
val requestSignature = "GATG2313LSuCYRbPASD4axJ26XyTk="
val request = Request(Method.GET, "$urlProto://$urlBase/voicemail/transcript")
.query("ApplicationSid", "AP1234567890abcdefg")
.query("ApiVersion", "2010-04-01")
.query("Called", "")
.query("Caller", "client:Anonymous")
.query("CallStatus", "ringing")
.query("CallSid", "CA1234567890abcdefg")
.query("From", "client:Anonymous")
.query("To", "")
.query("Direction", "inbound")
.query("AccountSid", "AC1234567890abcdefg")
.header("X-Twilio-Signature", requestSignature)
.header("I-Twilio-Idempotency-Token", "337aaaa-1111-2222-3333-ffffb5333")
.header("Content-Type", "text/html")
.header("User-Agent: ", "TwilioProxy/1.1")
.header("X-Forwarded-Proto", urlProto)
.header("Host", urlBase)
val response = handler(request)
response shouldHaveStatus OK // this fails, Status: expected:<200 OK> but was:<401 Unauthorized>
}
Иногда проверка не выполняется из-за Google Cloud. Я ранее размещал свой сервер в Google Cloud Functions, пока не обнаружил проблему, при которой GCF молча пропускает часть URI https://github.com/GoogleCloudPlatform/functions-framework-java/issues/90.
Также существует проблема, когда, если запрос «изменен», например, если я установил URL-адрес обратного вызова Twilio, чтобы включить параметр запроса, например
https://my-gcr-server.app.run/twilio/callback?type=recording
, то подпись Twilio игнорирует этот параметр, но при проверке аутентификации невозможно узнать, какие параметры игнорирует Twilio. То же самое верно, если заголовки изменены.
Есть ли рабочий метод проверки того, что запрос исходит от Twilio? Или альтернативное решение для валидации?
3 ответа
Проповедник разработчиков Twilio здесь.
В документации описывается, как создается подпись, и могут быть выявлены некоторые различия в способах тестирования. На вашем сервере алгоритм проверки подписи следующий:
- Возьмите полный URL-адрес URL-адреса запроса, который вы указываете для своего номера телефона или приложения, от протокола (https...) до конца строки запроса (все после?).
- Если запрос является POST, отсортируйте все параметры POST в алфавитном порядке (используя порядок сортировки в стиле Unix с учетом регистра).
- Просмотрите отсортированный список параметров POST и добавьте имя и значение переменной (без разделителей) в конец строки URL-адреса.
- Подпишите полученную строку с помощью HMAC-SHA1, используя свой AuthToken в качестве ключа (помните, что регистр вашего AuthToken имеет значение!).
- Base64 кодирует полученное хеш-значение.
- Сравните свой хэш с нашим, представленным в заголовке X-Twilio-Signature. Если они совпадают, тогда можно идти.
Вы используете запросы, поэтому можете отказаться от шагов 2 и 3.
Есть некоторые вещи, которые я вижу в этом алгоритме, которые могут повлиять на то, как вы тестируете валидатор.
В вашей ошибке тестирования в реальной жизни использовался URL-адрес
https://my-gcr-server.run.app/twilio
, но ваш тестовый сценарий из реального запроса использует
https://my-gcr-server.run.app/voicemail/transcript
. URL-адрес имеет значение при генерации подписи.
Ваш тест также добавляет параметры запроса к запросу, но трудно понять, в каком порядке будут эти параметры. Порядок параметров запроса в URL-адресе должен быть таким же, как URL-адрес, на который Twilio отправил запрос.
С другой стороны, если исходный запрос Twilio был
POST
request, то эти параметры должны быть добавлены как параметры формы, поскольку алгоритм принимает параметры формы, сортирует их и добавляет их к URL-адресу без разделителей.
Вы сказали:
Также существует проблема, если запрос «изменен», например, если я установил URL-адрес обратного вызова Twilio для включения параметра запроса, например https://my-gcr-server.app.run/twilio/callback?type= записи , то подпись Twilio игнорирует этот параметр, но при проверке аутентификации невозможно узнать, какие параметры игнорирует Twilio. То же самое верно, если заголовки изменены.
Это неправда, параметр запроса является частью URL-адреса, как я сказал выше. Twilio не игнорирует параметры, а обрабатывает их по описанному выше алгоритму. Что касается заголовков, помимо
X-Twilio-Signature
который используется для проверки подписи, они не играют роли.
Сказав все это, я не уверен, почему реальный запрос откажет валидатору, поскольку он должен обрабатывать все вещи, которые я обсуждал выше. Вы можете проверить код, используемый для проверки запроса, и получить подпись .
В вашем коде:
val uri: String = request.uri.toString()
val paramMap: Map<String, String?> = request.form().toMap()
logger.info { "Validating request with uri: $uri, paramMap: $paramMap, signature: $requestSignature" }
val isTwilioSignatureValid = twilioValidator.validate(uri, paramMap, requestSignature)
logger.info { "Request Twilio valid: $isTwilioSignatureValid" }
isTwilioSignatureValid
Можете ли вы гарантировать, что
uri
действительно ли это исходный URL-адрес, на который Twilio сделал запрос, а не URL-адрес, который был проанализирован на части и снова объединен с параметрами запроса в другом порядке? В
GET
запрос, делает
request.form().toMap()
вернуть пустой
Map
?
Извините, это не полный ответ, я не очень люблю Java / Kotlin разработчик. Я надеюсь, что это даст вам хорошее представление о том, на что нужно обратить внимание.
Я считаю, что проблема связана с отсутствием
form
использовать в своем запросе.
Request.form
сохраняет значения как
POST
параметры как часть тела HTTP запроса. В вашем случае кажется, что
401
вызывается из-за отсутствия параметров аутентификации внутри этого тела.
За основу возьмем образец твилио:
.form("CallSid", "CA1234567890ABCDE")
.form("Caller", "+12349013030")
.form("Digits", "1234")
.form("From", "+12349013030")
.form("To", "+18005551212")
Это написано как
HTTP
params и правильно распознается системой аутентификации, в то время как
.query
значения, как только что переданные как часть
URL
.
В вашем случае вы должны изменить их, чтобы они отправлялись как часть запроса тела HTTP. Например, такие:
.query("CallSid", "CA1234567890abcdefg")
.query("From", "client:Anonymous")
.query("To", "")
Вместо этого должно быть следующее:
.form("CallSid", "CA1234567890abcdefg")
.form("From", "client:Anonymous")
.form("To", "")
У меня были проблемы, потому что я тестировал ngrok для маршрутизации запроса на мой локальный сервер во время разработки. У меня было, я запускал алгоритм (см. Выше в ответе Филнаша в соответствии с документами Twilio )
Однако, пока я устанавливал обратный вызов для https ngrok enpoint, который Twilio использовал для вычисления подписи, фактический запрос, который пришел ко мне, был конечной точкой http, ngrok перенаправляет https на http в бесплатной учетной записи.
поэтому я тестировал конечную точку http, но Twilio вычислял конечную точку https.
когда я сказал Twilio выполнить обратный вызов на конечной точке http, неправильного направления ngrok не было, и подпись совпала!
Также я заметил из ссылок «подтвердить запрос:» и «получить подпись» выше в ответе Филнаша, что код пробовал как с портом (например, 443 или 80) в URL-адресе, так и без него, и принимает любую подпись как совпадение.