鉴权与签名
服务地址
| 用途 | 正式环境 | 测试环境 |
|---|---|---|
| 开发者控制台(页面) | https://imo.lanchr.com | https://im-open-test.lanchr.com |
| 控制台 API | https://imo.lanchr.com/api/open/console/v1 | https://im-open-test.lanchr.com/api/open/console/v1 |
| 开放 API(客服 V1 / 机器人 V2 / 小程序 V2) | https://im.lanchr.com | https://im-test.lanchr.com |
正式环境中控制台与开放 API 是两个不同的域名(imo.lanchr.com / im.lanchr.com),请勿混用。测试环境的开放 API 也可通过 https://im-open-test.lanchr.com 访问,与 https://im-test.lanchr.com 指向同一后端、行为一致(详见各 API 参考页的 Servers 一节)。
控制台 API
控制台接口基地址为 /api/open/console/v1,供开发者控制台使用:
| 接口 | 方法 | 请求 | 响应要点 |
|---|---|---|---|
auth/login | POST | {"phone":"<手机号>","password":"<密码>","channelId":1} | 返回 accessToken、expiresAt、developerId,并下发刷新 Cookie |
auth/qr/generate | POST | 无 | 返回 qrToken、qrImage(data:image/png;base64,...) |
auth/qr/status | GET | query qrToken | 返回 status(如 pending)与 expireIn(单位:秒) |
auth/refresh | POST | 依赖刷新 Cookie | 刷新 accessToken |
auth/logout | POST | 依赖刷新 Cookie | 注销当前会话 |
- 受保护接口需携带
Authorization: Bearer <accessToken>;与租户相关的接口还需X-Open-Tenant-Id,缺失时返回tenant context required。 - Access Token 有效期 7200 秒(2 小时);过期后调用
auth/refresh刷新。 - 刷新凭证保存在名为
open_refresh_token的 HttpOnly、Secure、SameSite=Strict Cookie 中,有效期 30 天。
控制台 Access Token 应只保存在页面内存中,第三方业务服务不要复用控制台 Token。
开放 API 签名 V2
机器人 V2、小程序运行时 API 以及客服 V1 API 都使用 App 凭证签名,采用同一套签名方案(签名 V2)——客服 V1 并不存在独立的「V1 签名规则」。每次请求携带:
X-Open-App-Id
X-Open-Credential-Id
X-Open-Timestamp
X-Open-Nonce
X-Open-Signature
Content-Type
⚠️ 必须显式设置
Content-Type。 除 GET 与 multipart 上传外,所有带 JSON 请求体的接口(POST/PUT)都必须发送请求头Content-Type: application/json。用
curl发送 JSON 时,若只写-d '...'/--data-binary '...',curl 会默认使用application/x-www-form-urlencoded。此时请求虽然签名正确,但平台无法解析请求体,会返回HTTP 415+code 415001(Content-Type不受支持)。拿到415请先查请求头,不要怀疑签名,也不要按服务端故障处理。⚠️
message里给出「收到的类型」后跟的那份「支持」清单不可信——它会把刚刚判定为不支持的那个类型原样列进去,例如「不支持的 Content-Type:application/x-www-form-urlencoded;charset=UTF-8,支持: [……,application/x-www-form-urlencoded, ……,*/*]」。这份清单是服务端已注册消息转换器的全局能力(含
*/*兜底),不是本接口实际接受的类型。不要用它判断该往哪个方向改,直接按接口要求改成application/json即可;也不要在代码里解析它。完整报文样例见错误与限制 → HTTP 状态。正确写法:
curl -X POST 'https://im.lanchr.com/api/open/v2/bot/messages' \-H 'Content-Type: application/json' \-H 'X-Open-App-Id: <app-id>' \-H 'X-Open-Credential-Id: <credential-id>' \-H 'X-Open-Timestamp: <timestamp-ms>' \-H 'X-Open-Nonce: <unique-nonce>' \-H 'X-Open-Signature: <hmac-sha256>' \--data-binary '{"receiveIdType":"user_id", ...}'
--data-binary与Content-Type缺一不可:前者保证请求体按原始字节计算签名,后者保证平台能解析为 JSON。
multipart 上传(POST /api/open/v2/bot/files)的签名
签名第 8 行是实际发出的整个 multipart 请求体的 SHA-256,它包含:每一段的分隔符行 --<boundary>、
每个分片的 Content-Disposition / Content-Type 头、分片之间与结尾的 \r\n、以及收尾的
--<boundary>--\r\n。因此必须先把请求体拼成确定的字节,再算哈希。
🚫
curl -F 'file=@a.png'直接发一定失败。boundary由 curl 随机生成,你在签名时无法预知 那串字节,服务端算出的哈希与你签的对不上,返回401+400001(signature verification failed)。 这不是凭证或权限问题,不要按鉴权故障排查。
正确做法是自己按下面的字节布局拼出请求体,对拼好的字节算哈希并签名,最后用 --data-binary 原样发出:
--<boundary>\r\n
Content-Disposition: form-data; name="file"; filename="a.png"\r\n
Content-Type: image/png\r\n
\r\n
<文件原始字节>
\r\n--<boundary>--\r\n
请求头写 Content-Type: multipart/form-data; boundary=<boundary>,这里的 <boundary> 是你自定义的
分隔符本身;而请求体每一段的起止行要在这个值前面再加两个 -。以分隔符
----limBoundary7f3a91c2 为例,两个地方分别长这样:
请求头(值就是分隔符本身,共 4 个前导 -):
Content-Type: multipart/form-data; boundary=----limBoundary7f3a91c2
请求体(每段前面再加 2 个 -,即 6 个前导 -;末段末尾还要跟 -- 收尾):
------limBoundary7f3a91c2
Content-Disposition: form-data; name="file"; filename="a.png"
Content-Type: image/png
hello-multipart
------limBoundary7f3a91c2--
上面为便于阅读按行展示。拼接时每一处换行都必须是
\r\n而不是\n——这既是 multipart 规范的要求, 也直接决定第 8 行的哈希:用\n拼出的字节与正确写法哈希不同,签名自然对不上。
注意签名规范串里不包含 Content-Type,所以 boundary 取值本身不影响签名;真正决定签名的是
「你拼出的字节」与「你实际发出的字节」是否一致。见下方 multipart 签名测试向量。
规范请求串由八行组成,最后一行不追加换行:
UPPERCASE_HTTP_METHOD
REQUEST_PATH
CANONICAL_QUERY
APP_ID
CREDENTIAL_ID
TIMESTAMP_MILLISECONDS
NONCE
LOWERCASE_SHA256_HEX_OF_RAW_BODY
规范串中的 APP_ID 为控制台的应用 ID,CREDENTIAL_ID 即创建应用或轮换凭证时展示一次的 credentialId(cred_ 开头)。控制台「凭证」页不展示当前 credentialId,错过弹窗后只能通过「轮换凭证」重新获取一对新凭证。使用 App Secret 对完整规范请求串执行 HMAC-SHA256,签名输出为小写十六进制。请求体必须使用实际发送的 UTF-8 原始字节计算,不能在签名后重新格式化 JSON。
CANONICAL_QUERY 规范化
第 3 行 CANONICAL_QUERY 不是原始 URL 查询串,必须按下述规则归一化后再参与签名,否则多参数接口会随机返回 401:
- 取原始查询串按
&拆分,保留重复参数与空值; - 对参数名与参数值做 UTF-8 百分号解码;
- 按参数名 ASCII 升序排列,同名参数再按参数值 ASCII 升序;
- 重新按 RFC 3986 做百分号编码(保留
-_.~,空格编码为%20,不使用+); - 以
key=value形式用&连接;没有查询参数时该行为空字符串。
示例(同一请求,仅参数顺序不同,规范化结果一致):
| 原始查询串 | CANONICAL_QUERY |
|---|---|
visitorId=abc&page=1&size=20 | page=1&size=20&visitorId=abc |
size=20&page=1&visitorId=abc | page=1&size=20&visitorId=abc |
visitorId=verify visitor with space | visitorId=verify%20visitor%20with%20space |
| (无参数) | 空字符串 |
const bodyHash = sha256(rawBodyBytes).toLowerCaseHex()
const canonical = [method, path, canonicalQuery, appId, credentialId, timestamp, nonce, bodyHash].join('\n')
const signature = hmacSha256(appSecret, canonical).toLowerCaseHex()
服务端默认接受前后 300 秒(5 分钟)的时间戳,并拒绝重复 Nonce。注意以下两点:
- Nonce 在签名校验通过后即被消费,有效期 300 秒。即使本次请求最终返回
5xx或业务失败,只要签名正确,该nonce就已被占用,不能重试同一请求——重试必须生成新的nonce(同时更新X-Open-Timestamp与X-Open-Signature)。 - 签名校验发生在应用状态校验之后:应用不是
ACTIVE时返回403(400005),此时不会消耗nonce。
签名测试向量
可用下列固定输入自测实现是否正确(appSecret = "test-secret",请求体 {"a":1}):
| 项 | 值 |
|---|---|
| METHOD | POST |
| REQUEST_PATH | /api/open/v2/bot/messages |
| CANONICAL_QUERY | (空字符串) |
| APP_ID | app_test |
| CREDENTIAL_ID | cred_test |
| TIMESTAMP | 1700000000000 |
| NONCE | abc123 |
bodyHash(body={"a":1} 的 SHA-256 小写十六进制) | 015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862 |
规范请求串(八行,末尾无换行,共 136 字节):
POST
/api/open/v2/bot/messages
app_test
cred_test
1700000000000
abc123
015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862
HMAC-SHA256("test-secret", 规范请求串) 小写十六进制结果为:
453c2fa3608897dd224e065747f9f035bafc7ada564cc807d1dacae9fdb8a51f
请用相同的八行拼接方式对上述输入自测;如果你的输出与之一致,说明第 3 行(空查询串)与第 8 行(原始字节哈希)的处理正确。
签名测试向量(multipart 上传)
上传接口的签名只要把第 2 行换成上传路径、第 8 行换成拼好的 multipart 字节的哈希即可。
下面这份输入可直接复现(appSecret = "test-secret",分隔符固定为 ----limBoundary7f3a91c2,
文件内容为 15 字节 ASCII hello-multipart,文件名 a.png、Content-Type: image/png):
| 项 | 值 |
|---|---|
| METHOD | POST |
| REQUEST_PATH | /api/open/v2/bot/files |
| CANONICAL_QUERY | (空字符串) |
| APP_ID | app_test |
| CREDENTIAL_ID | cred_test |
| TIMESTAMP | 1700000000000 |
| NONCE | def456 |
| bodyHash(multipart 体 163 字节的 SHA-256 小写十六进制) | e3f72feb3b048b85d8bbbd9e614053d93dcd38a41ea05723abad07bc064e2f04 |
需要参与哈希的请求体共 163 字节,逐字节展开如下(␍␊ 即 \r\n,开头无空行、结尾保留 ␍␊):
------limBoundary7f3a91c2␍␊
Content-Disposition: form-data; name="file"; filename="a.png"␍␊
Content-Type: image/png␍␊
␍␊
hello-multipart␍␊
------limBoundary7f3a91c2--␍␊
规范请求串(八行,末尾无换行,共 133 字节):
POST
/api/open/v2/bot/files
app_test
cred_test
1700000000000
def456
e3f72feb3b048b85d8bbbd9e614053d93dcd38a41ea05723abad07bc064e2f04
HMAC-SHA256("test-secret", 规范请求串) 小写十六进制结果为:
9765639f56f671947add6a064b7b84104dd51774ec40c250b26dff810bb3d2de
拼接与签名的参考实现(Python):
import hashlib, hmac
boundary = "----limBoundary7f3a91c2"
file_bytes = b"hello-multipart"
# 1. 先拼出确定字节的 multipart 请求体
body = b"".join([
f"--{boundary}\r\n".encode(),
b'Content-Disposition: form-data; name="file"; filename="a.png"\r\n',
b"Content-Type: image/png\r\n",
b"\r\n",
file_bytes,
f"\r\n--{boundary}--\r\n".encode(),
])
# 2. 对拼好的字节算哈希
body_hash = hashlib.sha256(body).hexdigest()
# 3. 八行规范串 -> 签名
canonical = "\n".join(["POST", "/api/open/v2/bot/files", "", "app_test",
"cred_test", "1700000000000", "def456", body_hash])
signature = hmac.new(b"test-secret", canonical.encode(), hashlib.sha256).hexdigest()
# 4. 原样发送拼好的字节,并让 Content-Type 里的 boundary 与之一致
# 把第 1 步的 body 原样写到 body.bin,再发送:
curl -X POST 'https://im.lanchr.com/api/open/v2/bot/files' \
-H 'Content-Type: multipart/form-data; boundary=----limBoundary7f3a91c2' \
-H 'X-Open-App-Id: <app-id>' \
-H 'X-Open-Credential-Id: <credential-id>' \
-H 'X-Open-Timestamp: <timestamp-ms>' \
-H 'X-Open-Nonce: <unique-nonce>' \
-H 'X-Open-Signature: <hmac-sha256>' \
--data-binary @body.bin
关键点是先定字节、再签名、原样发送。只要这三步用的是同一份字节,
boundary取什么值都能通过。
错误响应契约
失败响应统一为 ApiResult({code, message, value, reqNo}),成功以 code == "000000" 判定。区别在于失败落在哪个 HTTP 状态:
- 统一失败响应体(鉴权失败、参数问题、业务守卫拒绝):
{
"code": "400008",
"message": "消息未投递给此应用",
"value": null,
"reqNo": "9f1c2d3e4a5b4c6d8e7f0a1b2c3d4e5f"
}
- HTTP 状态(请求头校验、签名、限流等由鉴权过滤器直接返回):
| HTTP | code | 触发场景 |
|---|---|---|
400 | 400006 | 请求体/参数校验失败(缺必填字段、类型转换失败、非法枚举) |
401 | 400001 | 缺少请求头、时间戳超窗、应用不存在、凭证无效(如轮换后已过 24 小时宽限期的旧凭证)、签名不匹配、nonce 重复 |
403 | 400005 | 应用未处于 ACTIVE(未发布、审核中、已停用) |
403 | 400007 | 缺少必需的 Scope |
403 | 400008 | 业务守卫拒绝(资源不属于当前应用、机器人未安装到目标、fileKey 无效或已过期) |
403 | 400101 | 客服 V1:访客已停用 |
404 | 400100 / 400300 / 400401 | 客服 V1:访客不存在 / 会话不存在或关闭失败 / 客服账号不存在 |
406 | 406001 | 请求头 Accept 无法被满足(接口只返回 application/json) |
409 | 400400 | 客服 V1:分配客服失败 |
413 | 413001 | 请求体超过限制(普通请求约 256 KiB;机器人文件上传约 22 MiB) |
415 | 415001 | 请求体 Content-Type 不受支持(常见于漏发 Content-Type: application/json) |
429 | 429001 | 应用配额(每分钟/每日)或并发超限 |
500 | 500000 | 平台内部错误 |
Content-Type缺失不是500:带 JSON 请求体但未声明Content-Type: application/json时,平台返回415+415001,属于可自行修正的客户端问题。- 客服 V1 的业务失败也是 4xx:访客不存在、会话不存在、访客已停用等一律以对应的
4xx+ 业务code返回,旧的「HTTP200+ 业务code」与「HTTP500+500000」两种行为均已取消。唯一例外是「查无此人」类结果:agent/status(exists:false)与message/list(空列表)仍返回200+code 000000。判据始终是code == "000000"。 - 控制台接口(
/api/open/console/**)使用各自的错误契约,不适用本节。 - 响应中的
reqNo是排障的唯一凭据,请写入日志。
reqNo 是排障的唯一凭据,请写入日志。完整状态码与错误码清单见错误与限制。
安全边界
App Secret、Webhook Secret 和刷新凭证只能在服务端保存。文档站不提供把 Secret 发送到浏览器的在线调试器;请使用本地服务端脚本测试签名。