ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

免登录Token实战:匿名会话、并发签发与登录数据合并

免登录Token实战:匿名会话、并发签发与登录数据合并

“记录免登录获取token”这件事,第一次接到需求时我以为是半天的活,结果前后改了三版才收口。免登录获取token,说白了就是给还没注册、没登录的访客发一张临时入场券,让系统能认得“还是刚才那个人”,从而把购物车、草稿、浏览足迹、客服会话这些东西挂在他名下。它解决的不是身份认证问题,而是匿名状态下的连续性问题。看起来是个很小的技术点,但它牵扯到令牌形态选型、并发签发、防刷限流、匿名转登录的数据接管,以及一堆只有在真机上才会暴露的边界情况。这篇内容适合正在做游客模式、匿名会话、免登录表单暂存的前后端同学看,也适合已经上线但被“用户一刷新购物车就空了”这类问题困扰的人对照排查。下面我按自己实际落地的顺序,把这个东西从头到尾拆一遍。

1. 免登录 token 解决的是连续性问题而非身份问题

很多人一上来就把免登录 token 和登录态 token 混为一谈,用同一套签发逻辑、同一套权限校验,最后要么权限放大,要么维护成本失控。这两个东西的定位从根上就不一样:登录态 token 回答的是“你是谁”,免登录 token 回答的是“你还是不是刚才那个”。前者是身份凭证,后者是会话粘合剂。

1.1 匿名访客真正需要被记住的四类场景

把需求摊开来看,匿名状态下需要被记住的东西其实就那么几类,搞清楚这几类,令牌该存什么、存多久、能不能撤销,基本就有答案了。

第一类是过程性数据。用户在商品详情页选了规格、调了数量、点了加购,但还没登录。这类数据的特征是:价值随时间快速衰减,丢了用户会烦但不会造成资损。典型处理是存 7 天,登录后合并。

第二类是偏好性数据。比如用户把站点切成了深色模式、选了某个城市、调了列表排序。这类数据几乎不衰减,甚至可以永久保留,但数据量极小,一个键值对就能装下。

第三类是风控标识。系统需要知道“这一串请求是不是来自同一个来源”,用来做频率限制、异常行为识别。这类标识不需要用户可读,也不需要跨设备,重点是稳定和不可伪造。

第四类是实验分桶。灰度发布、A/B 实验需要一个稳定的随机键来分流,如果每次请求都重新随机,用户会在不同版本之间反复横跳,实验数据直接报废。这类分桶键要求的是“同一访客永远落在同一桶”。

把这四类需求对齐之后你会发现,它们对令牌的要求并不一致:过程性数据要求可撤销、可查询;风控标识要求不可伪造、短周期;分桶键要求绝对稳定。这就意味着,指望用一个万能令牌包打天下是不现实的,实际落地时通常是“一个主令牌 + 若干辅助字段”的组合。

1.2 免登录令牌和登录态令牌的边界对照

我在项目里画过一张边界表,后来每次有新同学加入都直接甩给他看,比讲半天管用。这张表的核心是:两者的权限范围必须是硬隔离的,不能靠“约定”。

维度登录态令牌免登录令牌
有效期2 小时(配合刷新令牌)7 天,滑动续期
可访问数据本人全部业务数据仅匿名会话数据
撤销方式服务端黑名单 + 版本号直接删除对应记录
存储位置内存 + 安全存储本地存储,可被脚本读取
是否含用户标识含用户 ID仅含匿名 ID
校验强度验签 + 查库 + 设备校验验签为主
跨端一致性需要不需要

这张表里最关键的一行是“可访问数据”。免登录令牌在服务端必须被识别为“匿名主体”,任何需要用户身份才能访问的接口,拿到匿名令牌都应该直接返回未授权,而不是返回空数据。这两者的区别很大:返回未授权,前端能明确知道要走登录流程;返回空数据,前端会以为用户真的没有数据,然后就可能出现“登录后购物车被空数据覆盖”这种事故。

1.3 直接复用会话 Cookie 会撞上的三堵墙

最省事的做法当然是直接用服务端的会话 Cookie,浏览器自动带、自动存,前端一行代码不用写。但这个方案在实际项目里几乎必然撞墙。

第一堵墙是跨端不通。小程序、原生 App、桌面客户端各有各的存储机制,Cookie 在这几个环境里根本不是一回事。你要做的是多端统一,那就必须有一个前端能主动读取、主动塞进请求头的令牌。

第二堵墙是前端读不到。会话 Cookie 通常是 HttpOnly 的,这是安全上的正确设计,但代价是前端拿不到它,没法在请求头里显式携带,也没法做本地判断“我到底有没有身份”。对于需要在前端做路由守卫、条件渲染的场景,这就很难受。

第三堵墙是横向扩展麻烦。会话 Cookie 天然绑定服务端会话存储,多实例部署时要么做会话共享,要么做粘性会话,前者多一层依赖,后者在扩缩容时会把用户打散。相比之下,自包含的签名令牌在任何一台机器上都能独立验签,扩容时不用考虑状态同步。

我的结论是:主链路用自包含签名令牌,特殊场景(比如需要防脚本读取的敏感操作)再用 HttpOnly Cookie 做补充。两者不冲突,各管一段。

2. 令牌形态选型:存储票、签名票与指纹票的取舍

令牌形态的选择直接决定了后面的架构复杂度。我见过三种主流做法,每一种都有人用得很顺,也见过用错场景之后维护得痛不欲生的。

2.1 服务端存储型临时票:可控但要多养一套存储

存储型临时票的逻辑很直白:服务端生成一个随机串作为键,把匿名会话数据存在这个键下面,设置过期时间,然后把随机串发给客户端。客户端每次请求带上这个串,服务端查一次存储就能拿到全部上下文。

这种做法的最大优势是完全可控。想撤销?删掉键就行。想查某个匿名用户干了什么?直接读键。想统计当前有多少活跃匿名会话?扫一遍键空间,或者维护一个计数。这些操作在签名令牌方案里都做不到,或者说做起来很别扭。

代价也很明确。一是多了一次存储往返,每个请求都要查一次,QPS 高的时候这是实打实的延迟。二是存储本身成了单点,缓存服务抖一下,全站匿名用户集体“失忆”。三是数据量会膨胀,匿名会话的创建成本极低,稍微被人刷一下就能堆出几百万个键,内存哗哗地掉。

如果决定走这条路,有几个参数我建议提前定好:过期时间给 7 天,并且做滑动续期;每个键的 value 控制在 2KB 以内,超出的部分拆到独立的业务表里,键里只存引用;键名加统一前缀,方便批量扫描和清理。还有一个容易忽略的点:过期时间要用存储层自带的 TTL,不要自己在应用层判断。应用层判断的话,过期数据不会自动消失,只是逻辑上不可见,存储会一直涨。

2.2 自包含签名令牌:无状态但要小心字段膨胀

签名令牌把匿名标识直接编码进令牌本身,服务端验签就能拿到内容,不需要查存储。这对高并发场景非常友好,签发和校验都是纯计算,横向扩容时没有任何状态同步负担。

令牌内容的设计有几个坑我踩过。第一个坑是字段越加越多。一开始只有匿名 ID 和过期时间,后来陆续加了设备指纹、渠道来源、实验分桶、语言偏好,最后令牌长度飙到 1.5KB,每个请求都要带这么大一个头,移动网络下很浪费。我的经验是:令牌里只放校验必需的和高频读取的字段,其他的一律放服务端,需要时再查。

第二个坑是把敏感信息塞进载荷。签名令牌的载荷默认是 Base64 编码,不是加密,任何人拿到都能解开看。所以里面绝对不能放手机号、身份证、精确地理位置这些东西,哪怕只是哈希过的也要谨慎。匿名令牌的载荷应该只有:匿名 ID、令牌类型标记、签发时间、过期时间、唯一标识、版本号。

第三个坑是签名算法选错。对称签名(HS256)实现简单、性能好,但密钥必须在所有验签节点上都有,一旦某个节点被攻破,全站令牌都能伪造。非对称签名(RS256、ES256)签发方持有私钥,验签方只需要公钥,安全性更好,代价是签名和验签都慢一些,密钥管理也更麻烦。对于匿名令牌这种低价值目标,我一般用 HS256 配合足够长的随机密钥,密钥长度不低于 32 字节,并且做定期轮换。

下面是我实际用的签发逻辑,Python 写的,逻辑直接照搬就能跑:

import time import uuid import hmac import hashlib import base64 import json SECRET = b"<32字节以上的随机密钥,从配置中心读取>" TOKEN_TTL = 7 * 24 * 3600 CLOCK_SKEW = 60 # 容忍 60 秒时钟偏移 def b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode() def issue_guest_token(fingerprint: str = "") -> str: now = int(time.time()) header = {"alg": "HS256", "typ": "JWT"} payload = { "sub": "g_" + uuid.uuid4().hex, # 匿名主体ID "typ": "guest", # 令牌类型,硬隔离用 "iat": now, "exp": now + TOKEN_TTL, "jti": uuid.uuid4().hex, # 唯一标识,做幂等和黑名单 "ver": 1, # 令牌版本,便于整体失效 } if fingerprint: payload["fp"] = hashlib.sha256( (fingerprint + "salt").encode() ).hexdigest()[:16] h = b64url(json.dumps(header, separators=(",", ":")).encode()) p = b64url(json.dumps(payload, separators=(",", ":")).encode()) signing_input = f"{h}.{p}".encode() sig = hmac.new(SECRET, signing_input, hashlib.sha256).digest() return f"{h}.{p}.{b64url(sig)}" def verify_guest_token(token: str) -> dict | None: try: h, p, s = token.split(".") except ValueError: return None signing_input = f"{h}.{p}".encode() expect = b64url(hmac.new(SECRET, signing_input, hashlib.sha256).digest()) if not hmac.compare_digest(expect, s): return None payload = json.loads(base64.urlsafe_b64decode(p + "==")) if payload.get("typ") != "guest": return None if payload["exp"] + CLOCK_SKEW < int(time.time()): return None return payload

这段代码里有两个细节值得单独说。hmac.compare_digest是必须的,普通字符串比较会因为提前返回而泄漏时序信息,理论上可以被用来逐字节爆破签名。CLOCK_SKEW也是必须的,多实例部署时机器之间难免有几十毫秒到几秒的时钟差,不留容忍窗口的话,会出现“刚签发就被判定过期”的诡异现象,我在测试环境被这个坑折磨过一整个下午。

2.3 设备指纹只能当辅助信号不能当主身份

有一类做法是完全不发令牌,纯靠设备指纹识别匿名用户。指纹的构成通常是浏览器 UA、屏幕分辨率、时区、字体列表、Canvas 渲染特征这一堆信息的哈希。

这个方案听起来很美——零存储、零令牌分发,但实际上非常脆弱。同一台设备,用户换个浏览器窗口大小、装个插件、系统更新一次,指纹就变了。反过来,同一批采购的办公电脑,硬件和系统完全一致,指纹会撞在一起,导致多个用户共享同一个匿名身份。

我的处理方式是:指纹只作为令牌的辅助校验字段,用来判断“这个令牌是不是被搬到别的设备上用了”,绝不作为主身份。令牌是主键,指纹是加分项。指纹对不上时,不直接拒绝,而是降低信任等级,触发二次确认或者限制敏感操作。这个降级策略比一刀切拒绝要温和得多,误伤率也低得多。

3. 签发链路拆解:从首个请求到令牌落地的完整路径

令牌形态定下来之后,真正麻烦的是签发时机和并发控制。这一段是我觉得整个方案里最容易出问题、也最少被文档提及的部分。

3.1 首个请求的三种状态判定

服务端在处理每个请求时,对令牌的判定其实有三种状态,而不是两种。很多人只写了“有令牌”和“没令牌”两个分支,漏掉了第三种,结果就是各种诡异 bug。

第一种是无令牌。客户端第一次访问,请求头里什么都没有。这时候直接签发一个新令牌,在响应头里返回,同时在响应体里也带一份,方便前端在不同环境下取用。

第二种是令牌有效。验签通过、未过期、类型匹配。这时候直接用令牌里的匿名 ID 处理业务,同时在满足续期条件时刷新有效期。

第三种是令牌无效,这是最容易被忽略的。无效的原因有很多:签名不匹配(换了密钥)、已过期、类型不对(比如把登录令牌传到了匿名接口)、格式损坏。这几种情况的处理方式应该不同:过期的话,如果令牌还能解出内容且未超过宽限期,可以基于旧的匿名 ID 签发新令牌,这样用户数据不丢;签名不对或格式损坏,只能签发全新的,旧数据自然作废。

我见过一种实现是“只要无效就当场签发新的”,结果就是密钥轮换那天,所有匿名用户的数据全丢,客服电话被打爆。加了宽限期逻辑之后,这个问题才算解决。

3.2 首屏并发请求导致的重复签发

这是我踩过最疼的一个坑。首页加载时,前端会同时发出七八个接口请求:推荐列表、分类、配置、消息数、购物车角标……这些请求几乎同时到达服务端,每个都发现“没有令牌”,于是每个都签发了一个新令牌。

结果就是:用户在首页的时候,系统里同时存在 8 个匿名身份。购物车角标用的是第 3 个令牌下的数据,点进详情页用的是第 5 个,加购之后写进了第 6 个。用户看到的现象是“我明明加购了,购物车里没有”。

这个问题有三种解法,我都试过,说下各自的适用场景。

第一种是前端加锁。在应用启动时先串行发一个“获取令牌”的请求,拿到之后再放行其他请求。这个方案最干净,但会拖慢首屏,多一次 RTT。如果令牌可以提前在服务端渲染时注入,那更好,连这次 RTT 都省了。

第二种是服务端去重。同一来源在极短时间窗口内(比如 3 秒)的签发请求,只真正签发一次,其他请求返回同一个令牌。去重的键可以用“客户端 IP + 指纹哈希”,窗口内第一个请求加分布式锁,后续请求等待结果。这个方案的代价是需要一次分布式锁,但它对前端完全透明。

第三种是签发后合并。允许重复签发,但在服务端记录一条“这些匿名 ID 属于同一来源”的关联关系,后续读数据时把这些 ID 下的数据合并读取。这个方案最复杂,但在已有系统上改造时最平滑。

我最后选的是第二种,因为前端改动最小,而且不依赖前端配合。锁的粒度要控制好,只锁“签发”这个动作,不锁业务请求,否则并发直接掉下来。

3.3 防刷限流:签发接口是天然的放大器

签发接口有个特点:它是整个系统里唯一一个不需要任何凭证就能无限调用的写接口。这意味着它天然是攻击者的首选目标。你签发一个令牌要占一块存储、消耗一点计算,攻击者调一万次就是一万块存储。

限流要分层做,我一般设三层:

层级限流维度阈值参考超限动作
第一层单 IP20 次/分钟直接拒绝,返回 429
第二层单指纹5 次/分钟直接拒绝
第三层全局峰值 QPS 的 30%触发验证码或排队

阈值不是拍脑袋定的,要先看真实分布。正常用户一天最多签发 3 到 5 次(换设备、清缓存、长时间未访问),所以一分钟 20 次已经非常宽松了。我把这个数字压到 10 次也没收到过投诉。

还有一个细节:签发接口要独立部署或独立限流,不要和主站业务接口共用同一个限流桶。否则被刷的时候,签发限流把整个桶打满,正常业务请求也被误伤。

3.4 懒创建:令牌只当钥匙不当仓库

有个很容易走偏的设计是:令牌一签发,就立刻在数据库里建一条用户记录、一条购物车记录、一条收藏记录。看着很规整,实际上是灾难。

匿名用户的转化率通常很低,十个访客里可能只有一个最终下单。提前建记录意味着百分之九十的写入是浪费的,而且这些垃圾记录会在数据库里长期堆积,影响查询性能,也影响运营数据的准确性。

我现在的做法是令牌只当钥匙,签发时不做任何业务写入,只产生一个令牌串。真正的业务数据在第一次有实质操作时才创建(加购、收藏、提交表单)。这样数据库里只有真正活跃过的匿名用户才有记录,数据量和真实活跃度基本对齐。

这个策略有个副作用要注意:如果业务侧有“统计今日访客数”这种需求,就不能靠查业务表了,得单独埋点。这一点最好在方案评审时就和其他团队对齐,避免上线后被问“为什么今天只有三百个访客”。

4. 匿名转正:登录瞬间的数据接管策略

匿名令牌生命周期里最复杂的一环是“转正”——用户从匿名变成登录用户的那一刻,之前挂在匿名身份下的数据要怎么处理。这一段处理不好,轻则数据丢失,重则串号。

4.1 合并操作必须放在一个事务里

登录成功之后,需要做三件事:读匿名数据、写入用户数据、删除匿名数据。这三步必须原子完成,否则任何一种中间状态都会出问题。

如果只做了前两步没做第三步,用户下次用同一个浏览器又变成匿名,会看到自己“残留”的匿名数据,可能被重复合并。如果做了第一步和第三步但第二步失败,数据直接丢了。

我的实现是把三步包在一个数据库事务里,同时用令牌里的唯一标识做幂等键。同样的令牌只允许合并一次,第二次进来直接返回“已处理”。这个幂等键可以存在一张合并记录表里,保留 30 天,过期后清理。

合并策略上分两种,要提前跟产品确认清楚:

覆盖式:用户数据优先级更高,匿名数据直接丢弃,只保留用户数据。适合偏好设置这类场景,因为用户登录后的设置是他明确的意图。

合并式:两边数据取并集,冲突时按规则取舍。适合购物车、收藏这类场景,因为匿名期间加的东西,用户是确实想要的。

购物车的合并规则特别容易出问题。同款商品在两边都存在时,是数量相加还是取较大值?我的选择是数量相加,但设置上限,因为用户加两次说明他真的想要两个。但如果加完之后超过库存,要截断到库存上限,并且给用户一个提示。这个提示很关键,否则用户会以为系统吞了他的商品。

4.2 同一设备切换账号的处理边界

比合并更麻烦的情况是:同一台设备上,A 用户登出,B 用户登录,中间还夹着一段匿名操作。

如果不做隔离,B 登录时会把 A 留下的匿名数据、甚至 A 的部分数据合并到自己名下,这就直接串号了。我的处理原则是:登出时必须彻底清空本地令牌,并且服务端把该匿名 ID 标记为不可合并。

具体做法是登出时生成一个新的匿名令牌,同时把旧的匿名 ID 写进一张“已废弃”表,保留 7 天。合并逻辑在读取匿名数据前,先查一下这个 ID 是否在废弃表里,在的话直接跳过合并。这个检查成本很低,但能挡住绝大多数串号问题。

还有一个容易漏的场景:用户在两个标签页里分别登录了两个账号。这种情况下,谁后登录谁覆盖本地令牌,先登录的那个账号的令牌就丢了。这个不用刻意处理,但要在文档里写清楚,避免被当成 bug。

4.3 清理策略:不清理就一定膨胀

匿名数据是一定会膨胀的。哪怕转化率再高,也会有大量“点进来看了三秒就走”的访客。这些数据不清理的话,一年之后表里百分之九十五都是死数据。

清理分两层。第一层是存储层的 TTL,缓存里的匿名会话必须靠存储自带的过期机制自动消失,不能靠应用层扫。第二层是定时任务,针对数据库里的业务数据,每天凌晨跑一次,删除“创建超过 30 天且从未有后续动作”的匿名记录。

清扫任务的参数也很讲究。批量删除时每批控制在 1000 条以内,批次之间 sleep 100 毫秒,避免长事务锁表。删除操作走从库还是主库要看业务压力,但一定要加 limit,我见过一个没加 limit 的删除任务直接把主库 IO 打满,线上告警连响半小时。

5. 安全边界与我在真机上踩过的坑

前面讲的都是正向设计,这一段讲讲反面的东西。免登录令牌因为“零门槛”,是很多安全事故的起点,我踩过的坑基本都在这个范围里。

5.1 关键参数的取值依据

参数不是随便填的,每个数字背后都应该有个理由。

过期时间 7 天。这个数字是从用户行为数据里推出来的:百分之八十的回访发生在 3 天内,7 天能覆盖到百分之九十五。再长的话,存储成本上升而收益趋近于零,而且匿名数据本来就是低价值的,放太久没意义。

密钥长度 32 字节以上。HS256 的密钥如果短于摘要长度,安全性会显著下降。32 字节是底线,我一般用 48 字节的随机串。

时钟偏移容忍 60 秒。这是权衡的结果,太长会削弱过期时间的意义,太短会误伤时钟不准的节点。60 秒对绝大多数集群都够用。

滑动续期的触发阈值是剩余有效期的三分之一。也就是剩余不到两天半时才续期。为什么不是每次请求都续?因为那等于每次请求都要重写令牌并回传,响应体凭空多出几百字节,还容易触发前端的状态更新导致的重复渲染。

5.2 五个我在真机上踩到的坑

第一个坑:令牌被塞进 URL 参数。有个前端的下载功能,图省事把令牌拼在查询串里传给后端。结果令牌进了 Nginx 访问日志、进了监控系统、进了用户浏览器的历史记录。后来改成放在请求头里,同时把日志里的令牌字段做了脱敏。这个坑的教训是:令牌只走请求头,任何情况下都不走 URL、不走 Cookie 明文字段、不写进前端日志。

第二个坑:匿名令牌能访问登录接口。权限校验写成了“有令牌就放行”,结果匿名令牌也能调到一堆本该登录后才能用的接口。修复方式是在中间件里做类型判断,把令牌类型和接口要求的身份等级做成两维矩阵,不匹配直接拒绝。这个矩阵最好写死在配置里,不要让业务代码自己去判断。

第三个坑:缓存服务故障导致签发雪崩。缓存一挂,所有请求都判定为“无令牌”,然后全部触发签发,签发又要写缓存,缓存超时,请求堆积,最后整个服务被拖死。修复方式是加降级:缓存不可用时,直接签发自包含令牌,不写缓存,业务侧接受“匿名数据暂时不可用”这个降级结果。降级的开关要能手动切,不能全自动。

第四个坑:指纹变化导致用户被当成新访客。有个用户反馈“每次重启浏览器购物车就空了”。排查发现是 Canvas 指纹在显卡驱动更新后变了,服务端做了严格的指纹校验,对不上就废弃旧令牌。改成“指纹对不上时降低信任等级但不废弃令牌”之后,问题解决。

第五个坑:令牌长度超出网关限制。因为载荷里越加越多字段,令牌长度超过了网关的请求头长度限制,表现为间歇性的 400 错误,而且只在特定用户身上出现,排查了很久。后来给载荷设了硬性上限,超过 512 字节就告警,再没出现过。

5.3 上线前我会过一遍的检查项

这套清单我在每个项目上线前都会走一遍,不长,但每一条都对应过一次真实事故:

  • 令牌里没有手机号、身份证、精确坐标等可识别信息,载荷解出来之后只能看到随机串和时间戳
  • 匿名令牌调登录后接口会被明确拒绝,返回码和未携带令牌时不同,方便前端区分
  • 并发十个请求首次访问,最终只产生一个匿名身份
  • 密钥轮换时有宽限期,轮换当天没有大面积数据丢失
  • 签发接口独立限流,被刷时不影响主站业务
  • 匿名数据有自动过期机制,且有过一次全量清理的演练
  • 登录合并是幂等的,重放同一个令牌不会重复合并
  • 登出会清空本地令牌,且旧匿名 ID 不会再被合并
  • 令牌在日志、监控、错误上报里都做了脱敏
  • 缓存故障时有降级路径,且降级开关可以手动控制

这套东西跑顺之后,免登录获取 token 其实就变成了一个很安静的基础设施,平时没人会想起它。但它的价值恰恰在这里:用户不会因为“忘了登录”而丢掉辛苦挑的东西,运营不会因为匿名数据混乱而看不懂报表,开发也不会因为一个令牌问题被半夜叫起来。我在几个项目里反复打磨这套方案的过程中最大的体会是,匿名身份的设计难点从来不在签发那一步,而在于你愿不愿意把它当成一个完整生命周期去对待——从签发、续期、限流,到合并、清理、降级,每一环都补上,这东西才算真的稳。

返回列表