2

我有一台使用 Kotlin 1.5、JDK 11、http4k v4.12 的服务器,还有使用 Google Cloud Run 托管的 Twilio Java SDK v8.19。

我使用 Twilio 的 Java SDK 创建了一个谓词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从 .env 文件中获取 Twilio auth 密钥的类。)

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")
    // note, changing these variables to be form parameters doesn't affect the result, Twilio's validator still says the request is invalid.

    .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

还有一个问题是,如果请求被“修改”,例如,如果我将 Twilio 回调 URL 设置为包含查询参数,例如https://my-gcr-server.app.run/twilio/callback?type=recording,则 Twilio 签名会忽略此参数,但在验证身份验证时无法知道哪些参数Twilio 无视。如果标题被更改,也是如此。

是否有验证请求来自 Twilio 的工作方法?还是替代验证解决方案?

更新

我刚刚发现 Twilio 的RequestValidator测试确实不足,只有一个例子RequestValidatorTest

4

2 回答 2

0

Twilio 开发人员布道者在这里。

该文档描述了如何创建签名,这可能会显示您测试方式的一些差异。在您的服务器上,检查签名的算法是:

  1. 获取您为电话号码或应用程序指定的请求 URL 的完整 URL,从协议 (https...) 到查询字符串的末尾(? 之后的所有内容)。
  2. 如果请求是 POST,则按字母顺序对所有 POST 参数进行排序(使用 Unix 风格的区分大小写的排序顺序)。
  3. 遍历已排序的 POST 参数列表,并将变量名称和值(不带分隔符)附加到 URL 字符串的末尾。
  4. 使用您的 AuthToken 作为密钥,使用 HMAC-SHA1 对结果字符串进行签名(请记住,您的 AuthToken 的大小写很重要!)。
  5. Base64 对生成的哈希值进行编码。
  6. 将您的哈希与我们在 X-Twilio-Signature 标头中提交的哈希进行比较。如果他们匹配,那么你很高兴去。

您正在使用GET请求,因此您可以放弃第 2 步和第 3 步。

我可以从这个算法中看到一些可能会导致您测试验证器方式的差异。

您在现实生活中的测试错误使用了 URL https://my-gcr-server.run.app/twilio,但您的真实请求中的测试脚本使用了https://my-gcr-server.run.app/voicemail/transcript. URL 在签名的生成中很重要。

您的测试还将查询参数添加到请求中,但很难知道这些参数的顺序是什么。URL 中查询参数的顺序应与 Twilio 发出请求的 URL 完全相同。

另一方面,如果原始 Twilio 请求是POST请求,则应将这些参数作为表单参数添加,因为算法采用表单参数,对它们进行排序并将它们附加到 URL,没有分隔符。

你说:

还有一个问题是,如果请求被“修改”,例如,如果我设置 Twilio 回调 URL 以包含查询参数,例如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确实是 Twilio 向其发出请求的原始 URL,而不是已被解析为多个部分并以不同顺序与查询参数组合在一起的 URL?在GET请求中,是否request.form().toMap()返回空Map

抱歉,这不是一个完整的答案,我不是 Java/Kotlin 开发人员。我希望这能让你很好地了解要研究的内容。

于 2021-09-21T03:01:06.577 回答
0

我遇到了麻烦,因为我正在使用 ngrok 进行测试,以便在开发时将请求路由到我的本地服务器。我有的是我正在运行算法(根据Twilio 文档,请参见上面 philnash 的回答)

但是,当我将回调设置为 Twilio 用来计算签名的 https ngrok 端点时,cam 对我的实际请求是 http 端点,ngrok 将 https 转发到免费帐户上的 http。

所以我正在测试一个 http 端点,但 Twilio 正在根据 https 端点进行计算。

当我告诉 Twilio 在 http 端点上回调时,没有 ngrok 误导并且签名匹配!

另外,我从 philnash 的回答中的“验证请求:和“获取签名”链接中注意到,代码在 URL 中尝试了使用端口(例如 443 或 80),并且没有并接受任一签名作为匹配项。

于 2021-11-23T06:59:15.253 回答