Skip to content

云端签发 Token

业务服务端保管长期应用凭证,并为通过身份和权限校验的设备或客户端签发短期 Token。AccessKeySecret 留在业务服务端;设备和客户端使用业务服务端下发的短期 Token。

Token 签发流程

HTTP API 由业务服务端调用。设备和客户端先使用产品已有的身份验证方式请求业务服务端,再由业务服务端完成授权检查和 Token 签发:

  1. 设备应用请求业务服务端,并提交或关联自己的 device_id
  2. 业务服务端确认请求来自该设备,再调用 /issue-device-access-token
  3. 业务服务端把 access_tokenexpires_at_ms 和业务需要的保存策略信息返回设备。设备把 Token 传给 TiStoreServiceUpdateToken()
  4. 客户端应用使用已有登录态请求业务服务端,并指定准备访问的 device_id
  5. 业务服务端确认当前账号有权访问该设备,再调用 /issue-app-access-token
  6. 业务服务端把 access_tokenexpires_at_ms 返回客户端。客户端用该 Token 创建访问对应设备的 TiStore 实例。

业务服务端需要同时判断当前账号是否已经登录、是否有权访问目标设备,以及当前请求用于设备上传还是客户端访问。云存储服务的签名校验不替代这些业务授权判断。

业务服务端提供给设备和客户端的接口属于你的应用,URL、请求字段和鉴权协议由现有业务系统决定。云存储服务定义的是业务服务端随后调用的 /openapi/v1 接口。

准备凭证

准备同一云存储应用下的 AppIdAccessKeyIdAccessKeySecret,并把长期密钥保存到密钥管理系统或受控运行环境。

参数获取方式使用位置
AppIdAccessKeyIdAccessKeySecret开通后下发,见申请开通只在可信业务服务端配置;客户端只取得 AppId
device_iddevice_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 地址

text
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 天。查询和删除复用同一个函数。

go
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 给出的值完全一致:

go
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 使用签名时的同一份字节。下面这段还要导入 bytesencoding/jsonos

go
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.ExpiresAtMs

expiresAtMs 是十进制字符串,随 Token 一起返回客户端或设备。字段类型见 HTTP API

日志记录接口名、状态码、coderequest_id。GET 请求把最终 Query 字符串传给同一个 signTGV1。当前公开路径直接使用 /openapi/v1/...;路径里出现非 ASCII 字符时,Canonical URI 按 HTTP API 做二次转义。

签发设备 Token

确认目标设备属于当前应用后,调用 /issue-device-access-token。请求体比客户端 Token 多一个 recording_retention_days。把返回的 Token 下发给对应设备,由设备传给 TiStoreServiceUpdateToken()AccessKeySecret 留在业务服务端。

go
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:

  1. 固定测试向量能够得到文档给出的签名结果。
  2. 设备通过业务服务端取得 device_access_token 后,完成一次有明确结束时间的上传,并从 on_result 收到 TISTORE_UPLOAD_COMPLETE
  3. 客户端通过业务服务端取得 app_access_token 后,为 Token 绑定的设备创建 TiStore 实例;listRecordings 返回成功响应。
  4. 没有目标设备访问权限的账号无法取得对应的 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

TiStore 开发文档