docs/embed-secure-mode.md
一句话:长期密钥(发布 Token
em_…)只放在你自己的服务器;访客浏览器里只有 30 分钟有效的短时令牌(ems_…)。
| 方式 | 发布 Token 在哪 | 风险 |
|---|---|---|
| iframe / 普通 Widget | 写在页面 HTML 或 URL hash 里 | 任何人「查看源代码」就能复制,等于公开密钥 |
| 安全模式 Widget | 仅环境变量 / 密钥管理,在服务端 | 浏览器拿不到长期密钥;还可先校验访客是否登录 |
生产环境对外嵌入,应优先用安全模式。
| 名称 | 格式 | 谁持有 | 用途 |
|---|---|---|---|
| 发布 Token | em_… | 仅你的服务端 | 向 WeKnora 换取短时令牌;在管理端「渠道密钥」查看 |
| 会话 Token | ems_… | 访客浏览器(iframe 内) | 调聊天、上传等 embed API;约 30 分钟过期,Widget 会自动刷新 |
访客浏览器 你的后端(shop 的服务器) WeKnora
│ │ │
│ 1. 加载 Widget │ │
│ data-token-endpoint │ │
│ (不含 em_) │ │
│─────────────────────────────►│ │
│ │ 2. 校验访客已登录(可选) │
│ │ 3. POST .../embed/:id/exchange │
│ │ Authorization: Embed em_… │
│ │─────────────────────────────►│
│ │◄──── session_token (ems_…) ───│
│◄── 4. { token, expiresIn } ──│ │
│ 5. iframe 用 ems_ 聊天 │ │
对应管理端「嵌入渠道 → 安全模式」里的两段代码:
data-token-endpoint="https://你的域名/weknora/embed-token"(没有 data-token)ems_… 返回给前端em_…)在你的业务后端新增一个 HTTP 接口(路径自定),要求:
入参:浏览器 GET 请求(Widget 会 fetch 这个地址)
你必须做:
401{ "token": "<ems_…>", "expiresIn": 1800 }调 exchange 的约定:
POST https://<weknora-host>/api/v1/embed/<channel_id>/exchange
Authorization: Embed <发布 Token em_…>
Origin: https://<你的业务站点> ← 须与渠道白名单一致,否则 403
服务端
fetch默认不带Origin,需要手动设置与白名单匹配的Origin头。
把 data-token-endpoint 改成上一步的真实 URL。完整示例在管理端「安全模式」Tab 可复制。
以下 <WEKNORA_HOST>、<CHANNEL_ID> 替换为实际值;发布 Token 放环境变量 WEKNORA_PUBLISH_TOKEN,不要写进前端。
const WEKNORA_BASE = 'https://<WEKNORA_HOST>';
const CHANNEL_ID = '<CHANNEL_ID>';
const ALLOWED_ORIGIN = 'https://shop.example.com'; // 与渠道白名单一致
app.get('/weknora/embed-token', async (req, res) => {
const hasSession = Boolean(req.cookies?.session_id);
const auth = req.headers.authorization || '';
if (!hasSession && !auth.startsWith('Bearer ')) {
return res.status(401).json({ error: 'unauthorized' });
}
const r = await fetch(`${WEKNORA_BASE}/api/v1/embed/${CHANNEL_ID}/exchange`, {
method: 'POST',
headers: {
Authorization: 'Embed ' + process.env.WEKNORA_PUBLISH_TOKEN,
Origin: ALLOWED_ORIGIN,
},
});
const body = await r.json();
if (!body?.data?.session_token) {
return res.status(502).json({ error: 'mint failed' });
}
res.json({ token: body.data.session_token, expiresIn: body.data.expires_in });
});
func embedTokenHandler(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" && r.Header.Get("Cookie") == "" {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
req, _ := http.NewRequest(http.MethodPost,
"https://<WEKNORA_HOST>/api/v1/embed/<CHANNEL_ID>/exchange", nil)
req.Header.Set("Authorization", "Embed "+os.Getenv("WEKNORA_PUBLISH_TOKEN"))
req.Header.Set("Origin", "https://shop.example.com") // 与渠道白名单一致
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode >= 300 {
http.Error(w, `{"error":"mint failed"}`, http.StatusBadGateway)
return
}
defer resp.Body.Close()
var body struct {
Data struct {
SessionToken string `json:"session_token"`
ExpiresIn int `json:"expires_in"`
} `json:"data"`
}
if json.NewDecoder(resp.Body).Decode(&body) != nil || body.Data.SessionToken == "" {
http.Error(w, `{"error":"mint failed"}`, http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"token": body.Data.SessionToken, "expiresIn": body.Data.ExpiresIn,
})
}
管理端「安全模式 → 服务端示例」Tab 会按当前渠道 ID 生成带真实 URL 的片段。
| 要放行的请求来源 | 白名单示例 |
|---|---|
| 聊天 iframe 所在源站(embed 页面) | https://app.example.com 或 https://embed.example.com |
| 你的取令牌后端(exchange 时带的 Origin) | https://shop.example.com |
若 embed 使用独立子域,两条都要加(embed 源站 + 业务后端源站)。
开发环境可临时使用 *;生产环境禁止 *。
| 现象 | 原因与处理 |
|---|---|
exchange 返回 401 / publish token required | 发布 Token 错误、已轮换,或误用了 ems_ 会话 Token |
exchange 或聊天 API 返回 403 origin not allowed | 白名单未包含当前请求的 Origin;服务端 exchange 记得手动加 Origin 头 |
| iframe 一直「等待 Token」 | token-endpoint 未返回 { token, expiresIn },或 CORS 未允许 Widget 所在源站访问你的接口 |
取令牌接口 502 mint failed | WeKnora 不可达、渠道已停用,或 exchange 响应格式不对 |
| 访客随便就能聊 | 取令牌接口未做登录校验——在 exchange 前加 Session / JWT 检查 |
frontend/public/weknora-widget.jsfrontend/src/api/embed/index.ts