云端签发 Token
业务服务端保管长期应用凭证,并为通过身份和权限校验的设备或客户端签发短期 Token。AccessKeySecret 留在业务服务端;设备和客户端使用业务服务端下发的短期 Token。
Token 签发流程
HTTP API 由业务服务端调用。设备和客户端先使用产品已有的身份验证方式请求业务服务端,再由业务服务端完成授权检查和 Token 签发:
- 设备应用请求业务服务端,并提交或关联自己的
device_id。 - 业务服务端确认请求来自该设备,再调用
/issue-device-access-token。 - 业务服务端把
access_token、expires_at_ms和业务需要的保存策略信息返回设备。设备把 Token 传给TiStoreServiceUpdateToken()。 - 客户端应用使用已有登录态请求业务服务端,并指定准备访问的
device_id。 - 业务服务端确认当前账号有权访问该设备,再调用
/issue-app-access-token。 - 业务服务端把
access_token和expires_at_ms返回客户端。客户端用该 Token 创建访问对应设备的TiStore实例。
业务服务端需要同时判断当前账号是否已经登录、是否有权访问目标设备,以及当前请求用于设备上传还是客户端访问。云存储服务的签名校验不替代这些业务授权判断。
业务服务端提供给设备和客户端的接口属于你的应用,URL、请求字段和鉴权协议由现有业务系统决定。云存储服务定义的是业务服务端随后调用的 /openapi/v1 接口。
准备凭证
准备同一云存储应用下的 AppId、AccessKeyId 和 AccessKeySecret,并把长期密钥保存到密钥管理系统或受控运行环境。
| 参数 | 获取方式 | 使用位置 |
|---|---|---|
AppId、AccessKeyId、AccessKeySecret | 开通后下发,见申请开通 | 只在可信业务服务端配置;客户端只取得 AppId |
device_id、device_secret_key | 测试设备的取值在开通时随凭证下发;正式设备由项目的设备管理流程分配,见申请开通 | device_id 可在三端使用;device_secret_key 只保存在受控设备环境,并按项目约定进入必要的设备管理流程 |
device_access_token | 业务服务端调用 /issue-device-access-token 取得 | 下发给对应设备的 C SDK |
app_access_token | 业务服务端校验用户与设备关系后,调用 /issue-app-access-token 取得 | 只下发给访问该 device_id 的客户端 |
配置 API 地址
https://api-tistore.tange365.com/openapi/v1/所有请求使用 TGV1-HMAC-SHA256 签名。用同一个经过固定测试向量验证的模块完成 URI、Query、Header 和 Body Hash 的规范化,各业务接口复用它。
实现请求签名
先完整复现固定测试向量,再接真实接口。signTGV1 接收最终 HTTP Method、Path、Query 字符串和 Body 原始字节,并生成全部请求头。发出请求时使用签名时的同一份 Method、Path、Query 和 Body。
下面的 Go 示例覆盖当前公开 HTTP API 的 POST 和 GET。Credential Scope 里的日期是签名当天加 7 天,这是 TGV1 协议约定,不表示 Token 有效 7 天。查询和删除复用同一个函数。
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
func sha256Hex(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}
func hmacSHA256(key []byte, data string) []byte {
mac := hmac.New(sha256.New, key)
_, _ = mac.Write([]byte(data))
return mac.Sum(nil)
}
func canonicalQuery(rawQuery string) (string, error) {
if rawQuery == "" {
return "", nil
}
values, err := url.ParseQuery(rawQuery)
if err != nil {
return "", err
}
for key := range values {
sort.Strings(values[key])
}
return strings.ReplaceAll(values.Encode(), "+", "%20"), nil
}
func signTGV1(method, path, rawQuery string, body []byte, appID, accessKeyID, accessKeySecret string, signingTime time.Time) (http.Header, error) {
if body == nil {
body = []byte{}
}
method = strings.ToUpper(method)
appID = strings.TrimSpace(appID)
utc := signingTime.UTC()
date := utc.Format("20060102T150405Z")
shortDate := utc.Format("20060102")
credentialScope := utc.Add(7 * 24 * time.Hour).Format("20060102") + "/tgv1_request"
payloadHash := sha256Hex(body)
query, err := canonicalQuery(rawQuery)
if err != nil {
return nil, err
}
headerLines := []string{
"x-tg-algorithm:TGV1-HMAC-SHA256",
"x-tg-app-id:" + appID,
"x-tg-date:" + date,
}
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch:
headerLines = append([]string{
"content-length:" + strconv.Itoa(len(body)),
"content-type:application/json",
}, headerLines...)
}
signedNames := make([]string, 0, len(headerLines))
for _, line := range headerLines {
name, _, _ := strings.Cut(line, ":")
signedNames = append(signedNames, name)
}
signedHeaders := strings.Join(signedNames, ";")
canonicalRequest := strings.Join([]string{
method,
path,
query,
strings.Join(headerLines, "\n"),
signedHeaders,
payloadHash,
}, "\n")
stringToSign := strings.Join([]string{
"TGV1-HMAC-SHA256",
date,
credentialScope,
sha256Hex([]byte(canonicalRequest)),
}, "\n")
dateKey := hmacSHA256([]byte("TGV1"+accessKeySecret), shortDate)
pathKey := hmacSHA256(dateKey, path)
signingKey := hmacSHA256(pathKey, "tgv1_request")
signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign))
header := make(http.Header)
header.Set("Authorization", fmt.Sprintf(
"TGV1-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
accessKeyID, credentialScope, signedHeaders, signature,
))
header.Set("X-Tg-Algorithm", "TGV1-HMAC-SHA256")
header.Set("X-Tg-App-Id", appID)
header.Set("X-Tg-Content-Sha256", payloadHash)
header.Set("X-Tg-Date", date)
header.Set("X-Tg-Signed-Headers", signedHeaders)
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch:
header.Set("Content-Type", "application/json")
header.Set("Content-Length", strconv.Itoa(len(body)))
}
return header, nil
}用固定测试向量核对签名。下面这次调用得到的 Authorization 必须与 HTTP API 给出的值完全一致:
signingTime, err := time.Parse(time.RFC3339, "2026-08-10T01:02:03Z")
if err != nil {
return err
}
header, err := signTGV1(
http.MethodPost,
"/openapi/v1/issue-device-access-token",
"z=last&a=two&a=one",
[]byte(`{"device_id":"device-001"}`),
"app-001",
"AK_TEST_001",
"test-secret-not-real",
signingTime,
)
if err != nil {
return err
}
if header.Get("Authorization") != "TGV1-HMAC-SHA256 Credential=AK_TEST_001/20260817/tgv1_request, SignedHeaders=content-length;content-type;x-tg-algorithm;x-tg-app-id;x-tg-date, Signature=2fd626b889af8cc4bd87fc9aaaf84f5fe8eb49438387308c7c6a42ce73b50c5b" {
return fmt.Errorf("TGV1 test vector mismatch")
}用签名结果发起请求。Body 使用签名时的同一份字节。下面这段还要导入 bytes、encoding/json 和 os:
body, err := json.Marshal(struct {
DeviceID string `json:"device_id"`
DurationSeconds int `json:"duration_seconds"`
}{
DeviceID: "device-001",
DurationSeconds: 3600,
})
if err != nil {
return err
}
const path = "/openapi/v1/issue-app-access-token"
header, err := signTGV1(
http.MethodPost, path, "", body,
os.Getenv("TISTORE_APP_ID"),
os.Getenv("TISTORE_ACCESS_KEY_ID"),
os.Getenv("TISTORE_ACCESS_KEY_SECRET"),
time.Now().UTC(),
)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "https://api-tistore.tange365.com"+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header = header.Clone()
req.ContentLength = int64(len(body))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var envelope struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Data struct {
AccessToken string `json:"access_token"`
ExpiresAtMs string `json:"expires_at_ms"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return err
}
if resp.StatusCode != http.StatusOK || envelope.Code != "OK" {
return fmt.Errorf("TiStore request failed: status=%d code=%s request_id=%s", resp.StatusCode, envelope.Code, envelope.RequestID)
}
accessToken := envelope.Data.AccessToken
expiresAtMs := envelope.Data.ExpiresAtMsexpiresAtMs 是十进制字符串,随 Token 一起返回客户端或设备。字段类型见 HTTP API。
日志记录接口名、状态码、code 和 request_id。GET 请求把最终 Query 字符串传给同一个 signTGV1。当前公开路径直接使用 /openapi/v1/...;路径里出现非 ASCII 字符时,Canonical URI 按 HTTP API 做二次转义。
签发设备 Token
确认目标设备属于当前应用后,调用 /issue-device-access-token。请求体比客户端 Token 多一个 recording_retention_days。把返回的 Token 下发给对应设备,由设备传给 TiStoreServiceUpdateToken()。AccessKeySecret 留在业务服务端。
body, err := json.Marshal(struct {
DeviceID string `json:"device_id"`
DurationSeconds int `json:"duration_seconds"`
RecordingRetentionDays int `json:"recording_retention_days"`
}{
DeviceID: "device-001",
DurationSeconds: 3600,
RecordingRetentionDays: 7,
})Path 使用 /openapi/v1/issue-device-access-token。签名、发送和解析与签发客户端 Token 相同;expires_at_ms 同样是十进制字符串。
签发客户端 Token
确认当前用户有权访问目标设备后,调用 /issue-app-access-token。把返回的 Token 下发给访问该 device_id 的客户端。客户端用它创建 TiStore 实例。设备上传使用 device_access_token。
确认 Token 可以使用
完成接入后,分别验证两类 Token:
- 固定测试向量能够得到文档给出的签名结果。
- 设备通过业务服务端取得
device_access_token后,完成一次有明确结束时间的上传,并从on_result收到TISTORE_UPLOAD_COMPLETE。 - 客户端通过业务服务端取得
app_access_token后,为 Token 绑定的设备创建TiStore实例;listRecordings返回成功响应。 - 没有目标设备访问权限的账号无法取得对应的
app_access_token。
日志记录接口名、状态码、错误码和 request_id。
签名测试通过,但 API 仍返回鉴权失败
确认签名输入就是最终发出的请求。重点核对 URL Path、同名 Query 的排序、JSON 原始字节、实际 Content-Length 和 UTC 请求时间。Credential Scope 中的日期应为“签名当天加 7 天”。
Token 签发成功,但客户端仍然查不到录像
确认签发 app_access_token 时绑定的 device_id 与客户端目标一致,并且录像上传已经完成。客户端使用 app_access_token。
参数、错误码和固定测试向量见 HTTP API。