【问题标题】:Shopify verify webhook call in ScalaShopify 在 Scala 中验证 webhook 调用
【发布时间】:2025-12-22 01:20:12
【问题描述】:

我正在为 Shopify webhook 实现一个简单的 web 服务,以便使用 Play2 进行调用。我想使用包含的“X-Shopify-Hmac-Sha256”标头参数验证呼叫是否来自 Shopify。

Shopify 文档仅包含 Ruby 和 Php 示例,我认为翻译起来并不难。好吧,我似乎在挣扎。

这是我的简单 Scala shopify util 对象:

    import play.api.mvc.Request
    import play.api.mvc.AnyContent
    import javax.crypto.Mac
    import javax.crypto.spec.SecretKeySpec
    import play.api.Logger
    import javax.crypto.SecretKey
    import org.apache.commons.codec.binary.Base64

    object ShopifyUtils {
        def verifyWebhookCall(request : Request[AnyContent], secretKey: String) : Boolean = {

          if (!request.headers.get("X-Shopify-Hmac-Sha256").isDefined)
              false
          else
          {
            val headerHash = request.headers.get("X-Shopify-Hmac-Sha256").getOrElse("")
            val body = request.body.asJson.get.toString

            Logger.info("json '" + request.body.asJson.get.toString + "' = " + encode(secretKey, request.body.asJson.get.toString) );
            Logger.info("body '" + request.body.toString() + "' = " + encode(secretKey, request.body.toString) )

            Logger.info("headerHash " + headerHash);

            val calcHash = encode(secretKey, body)
            headerHash.equals(calcHash)
          }
        }

        def encode(key: String , data: String): String = {
          val sha256_HMAC = Mac.getInstance("HmacSHA256");
          val secret_key = new SecretKeySpec(key.getBytes(), "HmacSHA256");
          sha256_HMAC.init(secret_key);

          return new String( Base64.encodeBase64( sha256_HMAC.doFinal( data.getBytes ) ) ).trim
        }
    }

我生成的哈希值与 Shopify 发送的哈希值不同。

要么我的共享密钥有误(我看不出它是怎么回事),要么我没有像 Shopify 那样散列相同的内容(我尝试了各种request.body 输出格式)。

感谢您收到任何提示/指南/建议。

提姆

【问题讨论】:

    标签: scala playframework-2.0 shopify sha256


    【解决方案1】:

    只需阅读原始 POST 正文并运行验证您的签名。通过将正文作为 JSON 抓取并将其转换为字符串,您可能会巧妙地操纵我们发送给您的响应。

    以下是我在一些使用 webhook(在 ruby​​ 中)的项目中的做法:

    class WebhookVerifier
      attr_accessor :expected_hmac, :data
      def initialize(options = {})
        @expected_hmac = options.fetch(:expected_hmac, '')
        content = options.fetch(:content, StringIO.new)
        content.rewind
        @data = content.read
      end    
    
      def valid?
        digest = OpenSSL::Digest::Digest.new('sha256')
        calculated_hmac = Base64.encode64(OpenSSL::HMAC.digest(digest, ShopifyApp.configuration.secret, data)).strip
        calculated_hmac == expected_hmac
      end
    end
    

    【讨论】:

    • 感谢 csaunders。是的,你是对的,我没有得到原始请求正文,而是它的预解析版本。
    【解决方案2】:

    感谢 csaunders 为我指明了正确的方向。

    我使用的是默认 BodyParser AnyContent,当请求的 Content-type 指定“application/json”时,它会将响应正文隐式转换为 json。

    我必须修改我的控制器对象以指定“原始”BodyParser:

        import play.api._
        import play.api.libs.iteratee.Enumerator
        import play.api.mvc.SimpleResult
        import play.api.mvc.ResponseHeader
        import play.api.libs.json._
        import play.Application
        import play.api.mvc._
    
        import javax.crypto.Mac
        import javax.crypto.spec.SecretKeySpec
        import play.api.Logger
        import javax.crypto.SecretKey
        import org.apache.commons.codec.binary.Base64
    
        object Purchase extends Controller { 
    
          val shopifyAppSecretKey = "11111111111111111111111111111111"
    
          def processPurchase() = Action( parse.raw ) {request =>
    
            val bodyRaw = request.body.asBytes(3000).getOrElse(Array[Byte]())
            val calculatedHash = encodeByteArray(shopifyAppSecretKey, bodyRaw)
            val shopifyHash = request.headers.get("X-Shopify-Hmac-Sha256").getOrElse("")
    
            Logger.info("keys '" + shopifyHash + "' || '" + calculatedHash + "' " + calculatedHash.equals(shopifyHash))
    
            val json: JsValue = Json.parse( new String(bodyRaw) )
    
            Ok( "Ok" ).as(HTML)
          }
    
          def encodeByteArray(key: String , data: Array[Byte]): String = {
            val sha256_HMAC = Mac.getInstance("HmacSHA256");
            val secret_key = new SecretKeySpec(key.getBytes(), "HmacSHA256");
            sha256_HMAC.init(secret_key);
    
            return new String( Base64.encodeBase64( sha256_HMAC.doFinal( data ) ) ).trim
          }
        }
    

    使用“原始”BodyParser 意味着您必须自己将字节数组转换为字符串,然后手动解析该字符串以获取您的 json,但这没有真正的问题。

    现在一切都按预期进行。

    谢谢,

    提姆

    【讨论】:

      【解决方案3】:

      如果您为特定事件创建了 shopify webhook。
      这里我为customers/update创建了一个webhook事件

      def ShopifyCustomerUpdateController = Action.async {
      implicit request =>
        println("data=============>  "+request.body.asJson.get)
        Future(Ok(""))  
      }
      

      【讨论】: