在线客服怎么识别登录用户:用签名防止别人冒充

只把用户 ID 传给聊天插件,等于谁都能冒充你的客户。正确做法是在服务器上签名:客服看到“已验证”的真实身份,别人改了浏览器里的 ID 也没用。

要让在线客服识别登录用户,分三步:在站点设置里开启“访客身份验证”、拿到密钥;在你的服务器上用密钥对用户 ID 做 HMAC-SHA256 签名;把 ID 和签名一起传给聊天插件。客服就能看到“已验证”的会员,别人改了浏览器里的 ID 也只会被当成匿名访客。

要点速览

  • 只在页面上放一个用户 ID,访客打开开发者工具就能改,等于谁都能冒充你的会员。
  • SealReply 认的是“用户 ID + 服务器算出的签名”,密钥以 ids_ 开头,只放在服务器上。
  • 签名用数据库里永远不变的 ID(主键、UUID),不要用邮箱、手机号。
  • 签名缺失或算错不会报错,访客只是变回匿名,所以上线后要自己登录真号检查一遍。
  • 所有套餐(包括免费版)都能用,只有所有者和管理员能生成密钥。

关键是中间那一步必须在服务器上做。下面讲清为什么不能只传一个用户 ID、三步具体怎么做(含 Node.js 和 PHP 代码)、客服那边会看到什么,以及签名老是不生效时该查哪几个地方。

A browser chat panel receiving a visitor card sealed by a key on the website server

只传一个用户 ID 有什么问题?

只传用户 ID,等于让访客自己报身份、客服照单全收。假设你的网站有自己的会员系统,你想让客服知道访客是哪个会员,于是在页面上写:

<!-- 不要这样做 -->
<script>
  window.chatUser = { id: "10231", name: "李先生", level: "钻石会员" };
</script>

页面上的任何东西,访客都能改。打开浏览器的开发者工具,把 10231 换成别人的会员号,或者把 level 改成“钻石会员”,再刷新页面——你的客服看到的就是改过的内容。这会带来两个真实的麻烦:

一是冒充。 对方以别人的身份进来问“我上个月那笔订单的收货地址是什么”“帮我把手机号改一下”,客服看到的是一个“已登录的老客户”,很容易就答了。会员号通常不是秘密:订单页、发票、客服截图里都可能出现。

二是看到别人的聊天记录。 登录用户的一个好处是能查自己以前的对话。如果身份是靠页面上一个明文 ID 认定的,那这个 ID 就成了别人聊天记录的钥匙。

安全领域管这类问题叫“不安全的直接对象引用”(IDOR):系统直接相信访客能改的那个编号,而没有核实他有没有资格用,OWASP 的说明讲的就是这个通病。

签名解决的正是这件事:ID 本身可以随便看、随便传,但只有你的服务器能算出跟它配套的那串签名。访客改了 ID,签名就对不上。

访客身份验证是怎么工作的?

访客身份验证是 SealReply 的一项站点设置:只有带着你服务器签名的用户 ID,插件才会认,其余一律当匿名访客。一共三个角色:

  • 密钥:一串只有你的服务器和 SealReply 知道的随机字符串,在站点设置里生成,加密存储。
  • 你的服务器:知道当前登录的是谁,用密钥对这个用户的 ID 算出签名。
  • 聊天插件:把 ID 和签名带上,由 SealReply 用同一个密钥重算一遍、比对。一致就认,不一致就当匿名访客。

Three steps, a signed in user, a server signing an identity card with a key, then the chat panel showing it

算法是 HMAC-SHA256,一种带密钥的哈希,标准写在 RFC 2104 里:同一个密钥、同一段文字,算出来永远是同一串 64 位十六进制;没有密钥的人算不出能对上的结果。它和验证 Webhook 用的是同一套东西,只是方向相反:Webhook 是你验别人发来的签名(见 Webhook 签名怎么验证),这里是你给自己的用户签名。

第一步:打开访客身份验证,拿到密钥

密钥在站点设置里生成,只显示一次。在工作台里打开“站点”,点开你的网站,找到“身份验证”这一节(卡片标题是“访客身份验证”):

  1. 点“开启”。
  2. 复制显示出来的密钥(以 ids_ 开头)。它只显示一次,关掉就看不到了。
  3. 把它当密码对待:存进服务器的环境变量,不要提交进代码仓库,更不要出现在任何网页里。

谁能开

访客身份验证所有套餐都能用,免费版也可以。但只有工作区的所有者和管理员能看到“开启”和“生成新密钥”,普通客服看不到。

还没装好插件的,先看网站怎么添加在线客服。

本文假设密钥存在环境变量 SEALREPLY_IDENTITY_SECRET 里。

第二步:在服务器上签名

服务器只需要对用户 ID 签名,别的都不用签。签名的内容就是用户在你系统里的 ID,结果用十六进制表示:

// Node.js
import { createHmac } from "node:crypto";

const identityHash = createHmac("sha256", process.env.SEALREPLY_IDENTITY_SECRET)
  .update(String(user.id))
  .digest("hex");
<?php
// PHP
$identityHash = hash_hmac('sha256', (string) $user->id, getenv('SEALREPLY_IDENTITY_SECRET'));

Python、Go 的写法在插件帮助文档里,算法完全一样——同一个密钥、同一个用户 ID,四种语言算出来的那 64 位十六进制字符串是一模一样的。

不要在浏览器里算签名

如果 identity_hash 是前端 JS 算出来的,密钥就一定写在前端代码里,谁都能看到、谁都能给任意 ID 签名。签名只能在后端算,页面上只输出结果。

签哪个 ID? 选一个在你数据库里永远不变的内部 ID(比如自增主键或 UUID),不要用邮箱或手机号。用户换了邮箱,签名就变了,SealReply 会把他当成另一个人,以前的对话就接不上了。

ID 最长 128 个字符,而且是原样比对的:不会去掉前后空格,也不会改大小写。所以签名时和传给插件时必须是同一个字符串,"42" 和 " 42" 是两个人。

第三步:把 ID 和签名传给插件

插件要在启动之前拿到 ID 和签名。在用户已登录的页面上,于 widget.js 之前把身份放进 window.sealreplySettings:

<script>
  window.sealreplySettings = {
    identity: {
      external_id: "10231",              // 用户在你网站上的 ID
      identity_hash: "<服务器算出的签名>",
      name: "李明",                       // 可选,客服能看到
      email: "liming@example.com",       // 可选
    },
  };
</script>
<script src="https://cdn.sealreply.com/widget.js" data-site-key="sk_…" async></script>

这两个字段要由服务端模板渲染出来(PHP、Laravel 的 Blade、Django 模板、Next.js 的服务端组件都一样),不要在浏览器里算。

单页应用(Vue、React 这类)里页面不会整页刷新,就在登录成功后调用:

SealReply.identify({
  external_id: "10231",
  identity_hash: "<服务器算出的签名>",
  name: "李明",
  email: "liming@example.com",
});

插件会以这个用户的身份重新加载一次。退出登录时调用:

SealReply.reset();

reset() 不能省。 用户退出后,浏览器里原来那个匿名 ID 已经打不开他的对话了(只有有效签名才能),但如果不调用 reset(),在共用电脑上接下来以匿名身份开始的那段对话,会被带到下一个在这台电脑上登录的用户名下。

识别登录用户之后,客服会看到什么?

客服会看到一个标着“已验证”的会员,换设备也是同一段历史。打开会话,右侧“访客”面板里会多出这些变化:

  • 访客显示**“已验证”**,下面写着他在你网站上的登录身份(就是你签名的那个 ID)。客服不用再问“请问您是用哪个账号下单的”。
  • 换手机、换电脑,只要是同一个 ID,就是同一个访客,之前的会话都接在一起。
  • 已验证的访客可以在插件菜单里查看自己在你网站上的历史对话,换设备也能看到。只会列出他第一次登录之后开始的对话,共用电脑上此前别人以匿名身份聊的内容不会出现。
  • 如果你开了聊天前表单,已验证的访客不会再被问一遍姓名和邮箱。

也正因为签名能打开这个用户的聊天记录,identity_hash 要像密码一样对待:不要放进网址、不要出现在别人能看到的页面上。聊天记录本身是加密存储、每个客户独立密钥的,细节见在线客服聊天记录安全吗。

哪些信息经过验证,哪些没有?

只有 external_id 经过签名。name 和 email 没有签名,它们只在访客资料里还空着的时候填进去,不会覆盖客服自己填过的内容。客服看到的姓名和邮箱,应当理解为“页面上是这么写的”。

字段 有没有签名 作用
external_id 有,靠 identity_hash 把访客认成同一个人、跨设备合并,打开他的历史对话
name、email 没有 访客资料空着时填进去,不覆盖客服填的
attributes 有,靠 attributes_hash 整体替换客服看到的会员信息(等级、订单数、消费额)

A sealed identity card passing into the chat while a copy with a broken seal becomes an anonymous card

签名缺失、算错,或者站点没开身份验证时,插件不会报错、不会不显示,访客只是保持匿名、照常聊天。这是故意这么设计的:你改模板改坏了,最坏的结果是客服少看到一个名字,而不是网站上的客服功能突然没了。

上线后自己当一回客户

验证失败不会有任何提示。上线后用一个真实账号登录你的网站,打开聊天发一句话,再到工作台看右侧“访客”面板是不是显示“已验证”。

能把会员等级、订单数也传给客服吗?

能,用同一个密钥再签一次就行。同一个机制还能把会员信息传给客服,显示在“访客”面板里,客服不用再去后台查。这些信息同样要签名,否则访客自己就能在浏览器里改成“钻石会员”。

要签名的文本是:先写用户 ID,然后把属性按键名排序,每个属性一行 键=值,各行之间用换行符连接。用户 42、属性 plan=Gold、orders=12,要签的就是这三行:

42
orders=12
plan=Gold
// Node.js
const attributes = { plan: "Gold", orders: "12" };
const message = [String(user.id), ...Object.keys(attributes).sort().map((k) => `${k}=${attributes[k]}`)].join("\n");
const attributesHash = createHmac("sha256", process.env.SEALREPLY_IDENTITY_SECRET).update(message).digest("hex");
<?php
// PHP
$attributes = ['plan' => 'Gold', 'orders' => '12'];
ksort($attributes, SORT_STRING);
$lines = [(string) $user->id];
foreach ($attributes as $k => $v) { $lines[] = "$k=$v"; }
$attributesHash = hash_hmac('sha256', implode("\n", $lines), getenv('SEALREPLY_IDENTITY_SECRET'));

和身份一起传:

window.sealreplySettings = {
  identity: {
    external_id: "42",
    identity_hash: "<用户 ID 的签名>",
    attributes: { plan: "Gold", orders: "12" },
    attributes_hash: "<属性的签名>",
  },
};

几条限制:最多 30 个属性;键最长 40 个字符,值最长 200 个字符,都不能带换行,键里不能有 =。键请只用英文字母、数字和下划线(比如 total_spent),这样各种语言的默认排序结果都一致——排序不一致是这里最常见的坑。值可以是任何文字。

签名正确时,这组属性会整体替换访客原来的会员信息;签名不对就整组忽略,身份识别照常进行。

密钥怎么保管、泄露了怎么办?

密钥只放服务器,怀疑泄露就立刻换。通用做法可以参考 OWASP 的密钥管理指南。

  • 密钥只放在服务器上(环境变量或密钥管理服务),不进仓库、不进前端打包产物、不进 App。
  • 签名不会过期。所以怀疑泄露时,唯一的办法是在站点设置里点“生成新密钥”:旧密钥立刻失效。

换密钥的顺序

“生成新密钥”会让旧签名立即失效。正确顺序是:先把新密钥部署到服务器、重启服务,再到后台点“生成新密钥”。反过来做,中间这段时间所有登录用户都会掉回匿名。

  • “关闭”会让所有访客重新变成匿名,已经记下的身份不会被删除。

签名不生效,访客还是匿名怎么办?

基本都是下面六种情况之一,插件不会告诉你是哪一种,对着表格查:

现象 多半是因为 怎么改
谁都没被认出来 后台没开 站点设置的“身份验证”要显示“已开启”才会校验
谁都没被认出来 签的字符串和 external_id 不是同一个 签的必须就是传给 external_id 的那个字符串:不是邮箱、不是 "user:42"、没有前后空格,数字 ID 先转成字符串
一发版全掉回匿名 服务器上的密钥是旧的或没复制全 完整复制(ids_ 开头,共 68 个字符),更新环境变量后重启服务
自己测没问题,真实用户不行 签名是在浏览器里算的 密钥已经泄露,马上生成新密钥,把签名挪到服务端
刷新一次才生效 window.sealreplySettings 写在 widget.js 之后 挪到那一行前面,或改用 SealReply.identify()
客服看到的名字是另一个人 CDN 整页缓存把上一个用户的签名发给了别人 带身份的页面不要做整页缓存

常见问题

identity_hash 需要像密码一样保密吗?

需要。拿到某个用户的 ID 和 identity_hash,就能在插件里打开他以前的对话,而且签名永远不会过期。不要把它放进网址、日志或别人能看到的页面。

可以用手机号或邮箱当 external_id 吗?

能用,但不建议。用户一换手机号或邮箱,签名就变了,SealReply 会当成一个新访客,以前的对话接不上。签数据库里的主键或 UUID,邮箱单独放在 email 里传就行。

签名错了会怎样,插件会不会不显示?

不会不显示,访客只是以匿名身份照常聊天,和没开身份验证时一样。这是故意的:模板改坏了,最多是客服少看到一个名字,网站上的客服不会因此消失。attributes_hash 错了也一样,只是会员信息被忽略,身份识别照常。

自己的 App 里也能识别登录用户吗?

能。在 App 里打开 SealReply 的全屏聊天页(站点“安装”里的“在 App 里使用”),在链接 # 后面加上 external_id、identity_hash、name、email,签名方式和网站完全一样。前提是站点已完成域名验证,而且密钥只能放在你的服务器上,不能打包进 App。用 uni-app 开发的项目,完整写法见uniapp 怎么接入在线客服。

识别登录用户要付费吗?

不用。访客身份验证所有套餐都能用,包括免费版;只是开启和生成新密钥需要工作区所有者或管理员来操作。

用户退出登录后,聊天要怎么处理?

在你网站的退出登录逻辑里调用 SealReply.reset()。没有有效签名本来就打不开他的历史对话,但 reset() 会换成一个全新的匿名访客,避免共用电脑上之后的匿名对话被算到下一个登录的人头上。

下一步做什么?

打开身份验证之后,客服看到的不再是“访客 2”,而是一个有名字、有会员等级、有历史对话的人。再配合线索状态和跟进提醒,一次咨询才不会聊完就断线。签名过的 external_id 还有一个用处:把订单回传回来,看出哪些会话后面真的下了单,做法见 GA4 怎么统计在线客服带来的转化。工作台里还有哪些能提前设好,见5 分钟上手 SealReply。