SPI签名机制说明
SPI配置说明
配置回调地址路径:登录抖音开放平台,控制台-生活服务商家应用-开发配置-SPI回调
注意:回调地址必须是 https 回调地址签名机制
请求到服务商的接口中存在两套签名,服务商可任选其中一种进行校验,推荐优先校验 Header 中的签名(安全性更高)。两个签名的计算方式存在细微差别。
签名规则(new)
URL 中的参数
参数名称 | 参数类型 | 参数描述 | 必需 |
client_key | string | 服务商的client_key | 是 |
timestamp | string | 时间戳, 单位毫秒 | 是 |
sign | string | 生成签名时忽略该字段 | 否 |
Header 中的 sign 参数
调用路径为抖音调用服务商的接口,会传递以下内容在 Header 中
参数名称 | 参数类型 | 参数描述 | 必需 |
x-life-clientkey | string | 服务商的client_key | 是(仅作应用标识用) |
x-life-sign | string | 新签名 | 是 验签用 |
该 sign 的生成方式:
- 1.以三方服务商的 client_secret 开头; 然后将除 sign 之外的 URL 参数以 key=value 的形式按 key 的字典升序排列; 若为 POST 请求, 还需要加上 HTTP 的 BODY, key 固定为 http_body(http_body 的内容不参与排序,加到最后)。最后所有的这些项以字符'&'串联起来即为待签名内容 str1. 发码接 口的示例如下(假设 client_secret 为 yyyyyy):
- 2.str1 的内容是 yyyyyy&client_key=xxxxxx×tamp=1624293280123&http_body=zzzzzz 【注意:client_key 参数需从 URL 中动态获取,请勿直接固定写死,否则会导致验签失败】
- 3.然后将待签名内容 str1 计算 SHA-256 哈希值,得到的结果即为 sign。
- 4.抖音将用于签名生成的 httpBody 以[]byte 类型请求服务商,服务商请不要将[]byte 反序列化成 object 再序列化 string 用于签名校验。这样会导致 json 中的字段顺序与抖音不符,同时若抖音侧将 httpBody 进行改动,也会导致签名校验不通过。
签名demo
Go
main.go
package main import ( "bytes" "fmt" "net/http" "douyin-sign-verify-go/pkg/verify" ) // 该示例演示如何在无网络环境下构造一个请求并进行新旧签名的验签 func main() { clientSecret := "yyyyyy" url := "https://svc.example/spi?client_key=xxxxxx×tamp=1624293280123" body := []byte("zzzzzz") req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err != nil { panic(err) } // 计算签名,并设置到请求中(模拟抖音侧的行为) q := req.URL.Query() newSig := verify.ComputeNewSignature(clientSecret, q, body) oldSig := verify.ComputeOldSignature(clientSecret, q, body) req.Header.Set("x-life-sign", newSig) q.Set("sign", oldSig) req.URL.RawQuery = q.Encode() // 服务商侧验签 okNew, expectedNew, providedNew := verify.VerifyNewSignatureFromRequest(req, clientSecret) okOld, expectedOld, providedOld := verify.VerifyOldSignatureFromRequest(req, clientSecret) fmt.Printf("Verify new (header x-life-sign): ok=%v expected=%s provided=%s\n", okNew, expectedNew, providedNew) fmt.Printf("Verify old (url sign): ok=%v expected=%s provided=%s\n", okOld, expectedOld, providedOld) }
verify.go(将该文档放到创建的pkg/verify目录下,示例module名称douyin-sign-verify-go)
package verify import ( "bytes" "crypto/md5" "crypto/sha256" "encoding/hex" "io" "net/http" "net/url" "sort" "strings" ) // BuildSignString 构造待签名字符串 str1。 // 规则: // 1) 以 client_secret 开头; // 2) URL 参数除 key=="sign" 外,按 key 字典升序遍历;每个 key 的多个值也按值字典升序逐个追加为 key=value; // 3) 若为 POST 请求需在末尾追加 http_body=原始字节内容(http_body 不参与排序,仅追加到最后)。 // 本函数无法感知 HTTP 方法,约定:当 body!=nil 时即视为需要追加 http_body(即便 len(body)==0)。 // 4) 各项之间用字符 '&' 连接。 func BuildSignString(clientSecret string, query url.Values, body []byte) string { var b strings.Builder b.WriteString(clientSecret) // 收集并排序所有非 sign 的键 keys := make([]string, 0, len(query)) for k := range query { if strings.EqualFold(k, "sign") { // 忽略 URL 中的 sign 参数 continue } keys = append(keys, k) } sort.Strings(keys) // 逐 key 的值按字典序追加 for _, k := range keys { vals := query[k] sort.Strings(vals) if len(vals) == 0 { b.WriteString("&") b.WriteString(k) b.WriteString("=") continue } for _, v := range vals { b.WriteString("&") b.WriteString(k) b.WriteString("=") b.WriteString(v) } } // 末尾追加 http_body(不参与排序) if body != nil { b.WriteString("&http_body=") b.WriteString(string(body)) } return b.String() } // ComputeNewSignature 计算新签名:sha256(str1) 的十六进制小写 func ComputeNewSignature(clientSecret string, query url.Values, body []byte) string { str1 := BuildSignString(clientSecret, query, body) sum := sha256.Sum256([]byte(str1)) return strings.ToLower(hex.EncodeToString(sum[:])) } // ComputeOldSignature 计算旧签名:md5(str1) 的十六进制小写 func ComputeOldSignature(clientSecret string, query url.Values, body []byte) string { str1 := BuildSignString(clientSecret, query, body) sum := md5.Sum([]byte(str1)) return strings.ToLower(hex.EncodeToString(sum[:])) } // VerifyNewSignatureFromRequest 校验请求头中的新签名(x-life-sign)。 // 返回:是否通过、期望值(小写)、请求方提供值(统一转小写)。 // 注意:读取 r.Body 后会回填原始内容,以保证后续处理不受影响。 func VerifyNewSignatureFromRequest(r *http.Request, clientSecret string) (ok bool, expected string, provided string) { var body []byte if r != nil && r.Body != nil && r.Method == http.MethodPost { data, err := io.ReadAll(r.Body) if err == nil { body = data } else { // 读取失败时按空 body 处理,但仍回填占位 body = nil } r.Body = io.NopCloser(bytes.NewReader(body)) } q := r.URL.Query() expected = ComputeNewSignature(clientSecret, q, body) provided = strings.ToLower(strings.TrimSpace(r.Header.Get("x-life-sign"))) ok = (provided != "" && provided == expected) return } // VerifyOldSignatureFromRequest 校验 URL 参数中的旧签名(sign)。 // 返回:是否通过、期望值(小写)、请求方提供值(统一转小写)。 // 注意:读取 r.Body 后会回填原始内容,以保证后续处理不受影响。 func VerifyOldSignatureFromRequest(r *http.Request, clientSecret string) (ok bool, expected string, provided string) { var body []byte if r != nil && r.Body != nil && r.Method == http.MethodPost { data, err := io.ReadAll(r.Body) if err == nil { body = data } else { body = nil } r.Body = io.NopCloser(bytes.NewReader(body)) } q := r.URL.Query() expected = ComputeOldSignature(clientSecret, q, body) provided = strings.ToLower(strings.TrimSpace(q.Get("sign"))) ok = (provided != "" && provided == expected) return }
JAVA
import java.util.*; import java.net.*; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.io.*; /** * 抖音 SPI 验签 Java 单文件示例(与 Go 单文件风格一致)。 * 仅使用标准库实现,严格遵循官方签名规则: * - 新签名(header: x-life-sign):sha256(待签名串) * - 旧签名(url: sign):md5(待签名串) * 待签名串拼接规则: * 以 client_secret 开头;按 key 的字典升序追加 key=value(忽略 key=="sign"), * 每个 key 的多个值按字典升序逐个追加; * 当 body 非空时,末尾追加 "http_body=" + 原始字节作为字符串(UTF-8); * 各项用 '&' 连接。 */ public class DouyinSignVerifyDemo { public static class SimpleRequest { public String method; public URI uri; public Map<String, String> headers; public byte[] body; } public static class VerifyResult { public boolean ok; public String expected; public String provided; public VerifyResult(boolean ok, String expected, String provided) { this.ok = ok; this.expected = expected; this.provided = provided; } } /** * 构造待签名字符串。 * 规则: * - 以 clientSecret 开头; * - 追加按 key 字典升序的 "key=value",忽略 key=="sign"; * - 对每个 key 的多个值按字典升序逐个追加; * - 当 body 非空时,末尾追加 "http_body=" + new String(body, UTF-8); * - 各项用 '&' 连接。 */ public static String buildSignString(String clientSecret, Map<String, List<String>> query, byte[] body) { if (clientSecret == null) clientSecret = ""; List<String> keys = new ArrayList<>(query != null ? query.keySet() : Collections.<String>emptySet()); Collections.sort(keys); List<String> parts = new ArrayList<>(); parts.add(clientSecret); for (String key : keys) { if ("sign".equals(key)) { // 验签时必须忽略 URL 中的 sign 参数 continue; } List<String> values = query.get(key); if (values == null || values.isEmpty()) { continue; } List<String> sortedValues = new ArrayList<>(values); Collections.sort(sortedValues); for (String v : sortedValues) { parts.add(key + "=" + (v == null ? "" : v)); } } if (body != null && body.length > 0) { parts.add("http_body=" + new String(body, StandardCharsets.UTF_8)); } return String.join("&", parts); } /** * 计算新签名:sha256 十六进制小写。 */ public static String computeNewSignature(String clientSecret, Map<String, List<String>> query, byte[] body) { String signStr = buildSignString(clientSecret, query, body); return toHexLower(sha256Bytes(signStr)); } /** * 计算旧签名:md5 十六进制小写。 */ public static String computeOldSignature(String clientSecret, Map<String, List<String>> query, byte[] body) { String signStr = buildSignString(clientSecret, query, body); return toHexLower(md5Bytes(signStr)); } /** * 校验新签名(header: x-life-sign)。 */ public static VerifyResult verifyNewSignature(SimpleRequest r, String clientSecret) { Map<String, List<String>> query = parseQuery(r != null && r.uri != null ? r.uri.getRawQuery() : null); String expected = computeNewSignature(clientSecret, query, r != null ? r.body : null); String provided = null; if (r != null && r.headers != null) { provided = r.headers.get("x-life-sign"); } boolean ok = provided != null && expected.equals(provided.toLowerCase(Locale.ROOT)); return new VerifyResult(ok, expected, provided); } /** * 校验旧签名(url: sign)。 */ public static VerifyResult verifyOldSignature(SimpleRequest r, String clientSecret) { Map<String, List<String>> query = parseQuery(r != null && r.uri != null ? r.uri.getRawQuery() : null); String expected = computeOldSignature(clientSecret, query, r != null ? r.body : null); String provided = null; List<String> signVals = query.get("sign"); if (signVals != null && !signVals.isEmpty()) { provided = signVals.get(0); } boolean ok = provided != null && expected.equals(provided.toLowerCase(Locale.ROOT)); return new VerifyResult(ok, expected, provided); } /** * 解析原始 URL 查询串为 Map<String, List<String>>。 * 使用 URLDecoder.decode(rawQuery, "UTF-8") 再拆分。 */ public static Map<String, List<String>> parseQuery(String rawQuery) { Map<String, List<String>> result = new LinkedHashMap<>(); if (rawQuery == null || rawQuery.isEmpty()) { return result; } String decoded; try { decoded = URLDecoder.decode(rawQuery, "UTF-8"); } catch (UnsupportedEncodingException e) { // UTF-8 一定存在,兜底使用原串 decoded = rawQuery; } String[] pairs = decoded.split("&"); for (String p : pairs) { if (p == null || p.isEmpty()) continue; int idx = p.indexOf('='); String key; String val; if (idx >= 0) { key = p.substring(0, idx); val = p.substring(idx + 1); } else { key = p; val = ""; } List<String> list = result.computeIfAbsent(key, k -> new ArrayList<>()); list.add(val); } return result; } /** 将字节数组转为十六进制小写字符串。 */ public static String toHexLower(byte[] bytes) { char[] HEX = "0123456789abcdef".toCharArray(); char[] out = new char[bytes.length * 2]; int i = 0; for (byte b : bytes) { out[i++] = HEX[(b >>> 4) & 0x0F]; out[i++] = HEX[b & 0x0F]; } return new String(out); } /** 计算 sha256 原始字节。 */ public static byte[] sha256Bytes(String s) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); md.update(s.getBytes(StandardCharsets.UTF_8)); return md.digest(); } catch (Exception e) { throw new RuntimeException(e); } } /** 计算 md5 原始字节。 */ public static byte[] md5Bytes(String s) { try { MessageDigest md = MessageDigest.getInstance("MD5"); md.update(s.getBytes(StandardCharsets.UTF_8)); return md.digest(); } catch (Exception e) { throw new RuntimeException(e); } } /** 演示:构造请求,计算并设置新旧签名,执行校验。 */ public static void main(String[] args) { String clientSecret = "yyyyyy"; String clientKey = "xxxxxx"; String timestamp = "1624293280123"; String baseUrl = "http://localhost:8080/spi/demo"; // 初始查询参数(不包含 sign) String rawQuery = "client_key=" + clientKey + "×tamp=" + timestamp; byte[] body = "{\"foo\":\"bar\",\"count\":1}".getBytes(StandardCharsets.UTF_8); // 计算新旧签名 Map<String, List<String>> queryForSign = parseQuery(rawQuery); String newSign = computeNewSignature(clientSecret, queryForSign, body); String oldSign = computeOldSignature(clientSecret, queryForSign, body); // 构造 headers,设置新签名 Map<String, String> headers = new LinkedHashMap<>(); headers.put("x-life-sign", newSign); // 在 URL 中追加旧签名 sign String rawQueryWithSign = rawQuery + "&sign=" + oldSign; URI uri = URI.create(baseUrl + "?" + rawQueryWithSign); // 构造 SimpleRequest SimpleRequest req = new SimpleRequest(); req.method = "POST"; req.uri = uri; req.headers = headers; req.body = body; // 注意:必须使用原始字节 // 执行校验 VerifyResult resNew = verifyNewSignature(req, clientSecret); VerifyResult resOld = verifyOldSignature(req, clientSecret); System.out.println("New signature verify: ok=" + resNew.ok); System.out.println(" expected=" + resNew.expected); System.out.println(" provided=" + resNew.provided); System.out.println("Old signature verify: ok=" + resOld.ok); System.out.println(" expected=" + resOld.expected); System.out.println(" provided=" + resOld.provided); // 预期两者均为 true if (resNew.ok && resOld.ok) { System.out.println("Both verifications passed."); } else { System.out.println("Verification failed."); } } }
签名规则(old)
说明
1、旧签名规则是指之前从抖音开放平台(open.douyin.com)接入网页应用及接口所使用的签名规则,迁移至服务商平台(partner.open-douyin.com)后,需要改成上方新的签名规则。
2、如不涉及迁移,可忽略该部分。
URL 中的 sign 参数(之前的签名方式)
如果服务商是新入驻/新接入的服务商,不需要关注该签名方法。
调用路径为抖音调用服务商的接口,会传递签名&公共 URL 参数
参数名称 | 参数类型 | 参数描述 | 必需 |
client_key | string | 服务商的client_key | 是 |
timestamp | string | 时间戳,单位为毫秒 | 是 |
sign | string | 签名 | 是 |
sign 的生成方式:
- 1.以三方服务商的 client_secret 开头; 然后将除 sign 之外的 URL 参数以 key=value 的形式按 key 的字典升序排列; 若为 POST 请求, 还需要加上 HTTP 的 BODY, key 固定为 http_body(http_body 的内容不参与排序,加到最后)。最后所有的这些项以字符'&'串联起来即为待签名内容 str1. 发码接口的示例如下(假设 client_secret 为 yyyyyy):
- 2.str1 的内容是 yyyyyy&client_key=xxxxxx×tamp=1624293280123&http_body=zzzzzz 【注意:client_key 参数需从 URL 中动态获取,请勿直接固定写死,否则会导致验签失败】
- 3.然后将待签名内容 str1 计算 MD5 值, 得到的结果即为 sign
- 4.抖音将用于签名生成的 httpBody 以[]byte 类型请求服务商,服务商请不要将[]byte 反序列化成 object 再序列化 string 用于签名校验。这样会导致 JSON 中的字段顺序与抖音不符,同时若抖音侧将 httpBody 进行改动,也会导致签名校验不通过。
签名校验方式选择
服务商一开始使用的平台为服务商后台
两种签名都可以校验,从安全性的角度考虑,建议使用新的签名规则(header 中的签名)进行校验。
服务商一开始使用的企业号开放接口(飞书文档),且从未使用过新的服务商平台(当前在线平台),仅支持校验旧版签名(URL 中的签名)。
服务商一开始使用的企业号开放接口(飞书文档),且根据指引迁移到了新的服务商平台(当前在线平台),代码中使用的应用的 client_key 换成了新的本地生活应用的 app_id(client_key),仅支持校验旧版签名(Header 中的签名)。
签名规则设计背景
服务商原有应用通过抖音开放平台申请的网站应用,迁移至服务商后台后,新本地生活应用的 app_id(client_key)将与原 client_key 不同。这一点在调用 spi 的场景里体现在 url 中的 client_key 和新的 client_key 中:
服务商未做过迁移:
url 中的 client_key 和 header 中的 x-life-clientkey 相同。
如果服务商不清楚迁移为什么动作,则也适用该规则。
服务商做过迁移:
url 中的 client_key 和 header 中的 x-life-clientkey 不同,header 中的 client_key 为迁移后的服务商平台上生活服务应用的 ck。
