Как проверить, что запрос поступает от 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 здесь.

В документации описывается, как создается подпись, и могут быть выявлены некоторые различия в способах тестирования. На вашем сервере алгоритм проверки подписи следующий:

  1. Возьмите полный URL-адрес URL-адреса запроса, который вы указываете для своего номера телефона или приложения, от протокола (https...) до конца строки запроса (все после?).
  2. Если запрос является POST, отсортируйте все параметры POST в алфавитном порядке (используя порядок сортировки в стиле Unix с учетом регистра).
  3. Просмотрите отсортированный список параметров POST и добавьте имя и значение переменной (без разделителей) в конец строки URL-адреса.
  4. Подпишите полученную строку с помощью HMAC-SHA1, используя свой AuthToken в качестве ключа (помните, что регистр вашего AuthToken имеет значение!).
  5. Base64 кодирует полученное хеш-значение.
  6. Сравните свой хэш с нашим, представленным в заголовке 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-адресе, так и без него, и принимает любую подпись как совпадение.

Другие вопросы по тегам