# HMAC 签名校验

> 校验 Shoplazza OAuth 回调与 Webhook 请求体的 HMAC-SHA256 签名：算法步骤、参考实现与常见坑。

Shoplazza 在几类场景使用 HMAC-SHA256 签名，让你的应用确认请求确实来自 Shoplazza、未被中间人篡改：

| 场景 | 用途 | 算法 |
|---|---|---|
| OAuth 安装回调 / 授权回调 | 验证 query 参数完整性 | HMAC-SHA256(query 排序拼接, client_secret) |
| Webhook 投递 | 验证 payload 完整性 | HMAC-SHA256(原始 body, client_secret) → base64 |

:::note
**本页定位**：HMAC 算法的统一中文参考。

**想跟着步骤把签名校验整合到代码里**：去 [用 Node.js 与 Express 构建应用](/docs/app/getting-started/create-public-app/develop-app)。
:::

## 通用要素

- 算法：**HMAC-SHA256**
- 密钥：**Client Secret**（合作伙伴中心生成，仅服务端可见）
- 编码：输出 **小写 hex digest**
- 对比：使用 **时间安全比较**（如 Node 的 `crypto.timingSafeEqual`）避免时序攻击

## 场景 1：OAuth 安装 / 授权回调签名

### 算法步骤

1. 从 query 参数中**移除** `hmac` 字段
2. 把剩余参数按 **key 字母升序** 排序
3. 用 `key=value` 拼接，分隔符为 `&`（**不要做 URL 编码**）
4. 用 `client_secret` 作为密钥，对拼接结果做 HMAC-SHA256，输出小写 hex
5. 与请求中的 `hmac` 做时间安全比较

### 示例输入

请求：

```
GET /auth/install?hmac=c4caf9b0...&install_from=app_store&shop=xxx.myshoplaza.com&store_id=1339409
```

移除 hmac 并排序后的待签名串：

```
install_from=app_store&shop=xxx.myshoplaza.com&store_id=1339409
```

### 参考实现

**Node.js**

```javascript
import crypto from 'crypto';

function hmacValidator(req, res, next) {
  const { hmac } = req.query;
  if (!hmac) {
    return res.status(400).json({ message: '缺少 hmac 参数' });
  }

  const map = { ...req.query };
  delete map.hmac;
  const sortedKeys = Object.keys(map).sort();
  const message = sortedKeys.map(key => `${key}=${map[key]}`).join('&');

  const generatedHash = crypto
    .createHmac('sha256', process.env.CLIENT_SECRET)
    .update(message)
    .digest('hex');

  if (!secureCompare(generatedHash, hmac)) {
    return res.status(400).json({ message: 'HMAC 校验失败' });
  }

  next();
}

function secureCompare(a, b) {
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
```

**Python**

```python
import hmac as hmac_lib
import hashlib
from urllib.parse import parse_qs

def verify_hmac(query_string: str, client_secret: str) -> bool:
    params = {k: v[0] for k, v in parse_qs(query_string).items()}
    received_hmac = params.pop('hmac', None)
    if not received_hmac:
        return False
    message = '&'.join(f'{k}={params[k]}' for k in sorted(params.keys()))
    generated = hmac_lib.new(
        client_secret.encode('utf-8'),
        message.encode('utf-8'),
        hashlib.sha256,
    ).hexdigest()
    return hmac_lib.compare_digest(generated, received_hmac)
```

**Go**

```go
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "net/url"
    "sort"
    "strings"
)

func VerifyHMAC(query url.Values, clientSecret string) bool {
    received := query.Get("hmac")
    if received == "" {
        return false
    }
    query.Del("hmac")
    keys := make([]string, 0, len(query))
    for k := range query {
        keys = append(keys, k)
    }
    sort.Strings(keys)
    parts := make([]string, len(keys))
    for i, k := range keys {
        parts[i] = k + "=" + query.Get(k)
    }
    message := strings.Join(parts, "&")
    mac := hmac.New(sha256.New, []byte(clientSecret))
    mac.Write([]byte(message))
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(received))
}
```

**Ruby**

```ruby
def verified_hmac?(hmac)
  sha256 = OpenSSL::Digest::SHA256.new
  query_string = "code=1vtke5ljOOL2jPds6gM0TNCeYZDitYB&shop=simon.myshoplaza.com"
  calculated_hmac = OpenSSL::HMAC.hexdigest(sha256, CLIENT_SECRET, query_string)
  ActiveSupport::SecurityUtils.secure_compare(calculated_hmac, hmac)
end
```

也可使用 Shoplazza OAuth SDK（Go）快速校验 shop 与 hmac：

```go
import (
  co "github.com/shoplazza-os/oauth-sdk-go"
  "github.com/shoplazza-os/oauth-sdk-go/shoplazza"
)

oauth := &co.Config{
                ClientID:     "s1Ip1WxpoEAHtPPzGiP2rK2Az-P07Nie7V97hRKigl4",
                ClientSecret: "{YOUR_CLIENT_SECRET}",
                Endpoint:     shoplazza.Endpoint,
                RedirectURI:  "https://3830-43-230-206-233.ngrok.io/oauth_sdk/redirect_uri/",
                Scopes:       []string{"read_shop"},
        }
oauth.ValidShop("xxx.myshoplaza.com")        // verify shop parameter

var requestUrl = "http://example.com/some/redirect_uri?code={authorization_code}&shop={store_name}.myshoplaza.com&hmac={hmac}"
query := strings.Split(requestUrl, "?")
params, _ := url.ParseQuery(query[1])
oauth.SignatureValid(params)                 // verify hmac
```

### 常见踩坑

- ❌ 排序后又重新做 URL 编码——会得到不同的哈希
- ❌ 字符串比较用 `==`——存在时序攻击风险
- ❌ 把 hmac 留在参数里参与签名——会自指
- ❌ 用 SHA-256 而非 HMAC-SHA256（少了密钥）

## 场景 2：Webhook payload 签名

Webhook 的验签与场景 1 不同：**对请求的原始 body 做 HMAC-SHA256、输出 base64**（场景 1 是对排序后的 query 串、输出 hex）。

每个 webhook 请求都带一个 base64 编码的 **`X-Shoplazza-Hmac-Sha256`** 请求头，由应用的 **Client Secret** 对请求体数据生成。验证时按下述算法计算 HMAC 摘要，与该 header 的值做时间安全比较，一致即说明请求来自 Shoplazza。**最佳实践：在应用响应 webhook 之前完成验证。**

### 算法步骤

1. 取请求的**原始 body 字节流**（不要先解析成 JSON 再重新序列化）
2. 用 `client_secret` 作为密钥，对原始 body 做 HMAC-SHA256
3. 把摘要做 **base64** 编码
4. 与请求头 `X-Shoplazza-Hmac-Sha256` 的值做时间安全比较

### 参考实现

**Ruby (Sinatra)**

```ruby
require 'rubygems'
require 'base64'
require 'openssl'
require 'sinatra'

# Shoplazza 的 Client Secret
SECRET = 'my_secret'

helpers do
  # 用共享密钥对请求体计算 HMAC，与请求头中的 HMAC 比较
  def verify_webhook(data, hmac_header)
    calculated_hmac = Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', SECRET, data))
    ActiveSupport::SecurityUtils.secure_compare(calculated_hmac, hmac_header)
  end
end

# 响应 HTTP POST 请求
post '/' do
  request.body.rewind
  data = request.body.read
  verified = verify_webhook(data, env["X-Shoplazza-Hmac-Sha256"])
  puts "Webhook verified: #{verified}"
end
```

**Node.js**

```javascript
import crypto from 'crypto';

// 必须拿到原始 body（如用 express.raw()），不能用解析后的 JSON 重新序列化
function verifyWebhook(rawBody, hmacHeader) {
  const calculated = crypto
    .createHmac('sha256', process.env.CLIENT_SECRET)
    .update(rawBody)
    .digest('base64');
  return crypto.timingSafeEqual(Buffer.from(calculated), Buffer.from(hmacHeader));
}
```

:::note
与场景 1 的差异：场景 1 对排序后的 query 串签名、输出 **hex**；场景 2 对**原始 body** 签名、输出 **base64**，请求头为 `X-Shoplazza-Hmac-Sha256`。
:::

## 相关

- [OAuth 鉴权](/docs/app/building-blocks/authentication/oauth-flow) — 场景 1 在 OAuth 流程中的位置
- [Webhook 概览](/docs/app/building-blocks/webhooks/overview) — 场景 2 的上下文（Webhook 订阅与生命周期）
- [用 Node.js 与 Express 构建应用](/docs/app/getting-started/create-public-app/develop-app) — 完整 Node.js + Express 实战
