1. 先把两个词摆正:token 和 regID 到底各管什么
项目标题就给了“token/regID说明”这几个字,没有上下文,没有字段定义,也没有接口文档。这种情况在接手别人的系统或者给外部团队做接入对接时特别常见——文档缺失,代码能跑,但没人说得清这两个标识各自负责什么、什么时候该用哪个。我见过不止一个团队在这件事上栽跟头:把 token 当主键存进业务表,结果 token 一刷新,历史数据全对不上;或者拿 regID 去做鉴权,一查发现 regID 是全局公开的,等于把钥匙挂在门把手上。
所以这篇东西我想按“实际工程里怎么用”的角度把它捋清楚。regID 是实体标识,token 是会话凭据——这句话听着像废话,但真的,把这句话钉在脑子里,后面 80% 的坑都不会踩。适合谁看:正在做设备接入、开放平台、SDK 授权、第三方应用对接的开发者,以及需要给这套东西写文档、做评审的同行。哪怕你只负责客户端,看完也能明白服务端为什么这么设计,联调的时候少吵几架。
先做个名词对齐。regID 这个叫法在不同系统里花样很多,有的叫 registerId,有的叫 regId,有的叫 deviceRegId、appRegId、entityId,本质都是一个东西:某个实体完成注册之后,系统分配给它的、长期不变的唯一标识。token 的叫法相对统一,access token、session token、auth token,本质也是一件事:一段时间内证明“我就是 regID 对应的那个实体”的凭证。
这个区别为什么重要?因为它决定了两个东西的所有属性都不一样。regID 要稳定、可存储、可索引、可展示、可以出现在日志和报表里;token 要短命、要保密、不能落库明文、不能进日志、不能出现在 URL 查询串里。你把这俩属性搞反了,系统不会立刻崩,但一定会在某个深夜出问题。
1.1 一个真实场景引出的困惑
举个我在某项目里遇到的例子。设备侧上报数据,请求头里带一个Authorization: Bearer xxx,body 里带一个regId。服务端代码大概是这么写的:先校验 token,通过之后从 token 里解析出用户身份,然后拿 body 里的 regId 去查设备信息,最后把数据写进device_data表,主键是token + regId的联合索引。
听起来没毛病,对吧?上线三个月后出事了:数据出现大量重复,同一条上报被记了两遍,还有一部分历史数据查不出来。排查了半天才发现两件事:第一,token 有效期 2 小时,客户端在过期前 10 分钟会主动刷新,刷新后拿到新 token,但同一个设备同一个时刻的重试请求可能带着新旧两个 token 打过来,于是同一个物理事件被记成两条;第二,运维做数据修复的时候按 token 分组,token 一过期就再也关联不回设备了。
根子就在于把“身份”和“凭证”焊在了一起。正确的做法是:regID 才是数据的归属维度,token 只负责“这一次请求是谁发的”,校验完就应该丢掉,最多在审计日志里留一个哈希后的短摘要。业务表里的外键永远指向 regID,绝不指向 token。
我后来给他们的改法是:device_data表主键改成reg_id + 业务时间戳 + 序列号,token 只在校验环节出现,校验通过后立刻转成(reg_id, tenant_id)放进行程上下文。改完重复率直接归零。
1.2 regID:实体的“身份证号”
我得把 regID 的定位说得再具体一点。它是注册行为的产物——一个实体(用户、设备、应用、租户、甚至一条配置项)向系统提交注册信息,系统审核通过后回写一个 regID,从此这个实体在系统内的所有引用都用这个号。
它的关键属性是这几条:
- 全局唯一:跨租户、跨环境之外的范围内不重复。这里的“范围”要提前定义清楚,是单库唯一还是全局唯一,很多事故就出在这个边界没写进文档。
- 永久不变:注册之后不允许修改。想改?那是另一个实体,走注销再注册。
- 不可推导:从 regID 不能反推出注册顺序、创建时间以外的敏感信息,更不能反推出密码、密钥。
- 可公开:出现在订单号、日志、客服工单里都不构成风险。这条特别重要,因为很多团队会把 regID 和 secret 搞混。
我倾向于把 regID 设计成带语义前缀的定长字符串,而不是纯数字自增。原因很实际:纯数字在跨系统传递时会被前端 JS 的 Number 精度截断(超过 2^53 就完蛋),而且纯数字没法一眼看出这是哪类实体,排查问题时得反查一次数据库。带前缀的字符串,人眼可读,机器可解析,日志里扫一眼就知道是哪类对象。
1.3 token:一次通行的“临时门禁卡”
token 的定位完全相反。它是短期凭证,签发出来就是为了被使用、被消耗、被丢弃的。它的属性是:
- 有期限:绝对过期时间一定要有,不能只靠“最后一次访问后 N 分钟过期”这种滑动窗口,否则一个长期活跃的会话永远不会过期。
- 可撤销:服务端要能主动让它立即失效,比如用户改密码、设备被挂失、检测到异常登录。
- 保密:明文只在签发的瞬间传输一次,之后服务端只存哈希。
- 不可预测:必须是密码学安全的随机数或者带签名的结构化数据,不能用时间戳、自增 ID、用户 ID 的哈希这类可枚举的东西。
我在评审时常用一个粗暴的检查:如果把这个 token 打印出来贴在墙上,会不会造成损失?会,说明它是 token,得按机密处理;不会,说明它其实是标识,你叫错名字了。这个检查方法土,但特别好用。
1.4 两者混用会出什么事
把常见的混用姿势和后果列一下,这张表我基本每次做架构评审都会拿出来对一遍:
| 混用姿势 | 表面现象 | 真实后果 | 正确做法 |
|---|---|---|---|
| token 存进业务表当外键 | 功能正常,查询也能跑 | token 刷新后历史数据断链,无法追溯 | 外键一律用 regID |
| 用 regID 做鉴权凭据 | 接口能通,测试也过 | regID 可枚举,越权风险极高 | 鉴权只认 token,regID 只做归属 |
| token 写进 URL 查询参数 | 调试方便,分享链接能复现 | 出现在访问日志、 Referer、浏览器历史里 | 放请求头,且只在 HTTPS 下传输 |
| regID 每次请求重新生成 | 单测全绿 | 同一实体产生多条记录,数据脏 | 注册时生成一次,持久化 |
| token 永不过期 | 用户体验“丝滑” | 一旦泄露就是永久后门 | 短过期 + 刷新机制 |
| regID 用自增数字 | 简单直观 | 暴露业务量,跨语言精度丢失 | 前缀 + 定长编码 |
这张表里我最想强调的是第二行。很多人下意识觉得“我都拿到 regID 了,说明这个实体是存在的,那用它鉴权有什么问题”。问题在于,存在性不等于授权。regID 是公开信息,攻击者拿到你的 regID 比拿到你家门牌号还容易,你不能因为别人知道你家门牌号就让他进门。
2. regID 的设计:怎么让一个标识十年不冲突
设计 regID 这件事,短期看是“生成一个不重复的字符串”,长期看是在为未来五到十年的数据迁移、分库分表、跨系统对账提前铺路。我在不同项目里见过自增主键、UUID、雪花 ID、哈希摘要、复合业务编码这几种方案,每一种都有它合适的场景,也都有它埋雷的方式。
选型的时候我一般问三个问题:预估的实体总量是多少?会不会有多个服务同时生成?这个 ID 需不需要人能看懂?三个问题的答案基本就锁定方案了。总量在千万级以内、单点生成、不需要人读懂,自增或者 UUID 都行;总量上亿、多节点并发生成,那就得考虑雪花类方案或者号段模式;需要人读懂、需要按类型分流,那就得用带语义的复合编码。
2.1 生成策略选型:四种常见路线的取舍
我把几条主流路线摊开说,因为它们各自的短板在真实项目里都会咬人。
自增序列最简单,数据库一个AUTO_INCREMENT就完事,索引友好,插入性能好。问题在于它把“生成权”绑死在单个数据库上,一旦要分库或者多活,冲突立刻出现。而且它天然暴露业务量——竞争对手下两单,看 ID 差了多少,就知道你一天卖多少货。
UUID v4解决了分布式生成的问题,128 位随机,碰撞概率低到可以忽略。但它是完全随机的,作为 InnoDB 的主键会导致严重的页分裂,写入性能随数据量增长断崖式下跌。我实测过一个千万级表,主键从自增换成 UUID v4,插入 QPS 掉了将近一半。UUID v7 或者 ULID 这类“时间有序的随机 ID”能缓解这个问题,前 48 位是毫秒时间戳,后面是随机位,兼顾了有序性和分布性,是我现在比较推荐的方向。
雪花类方案是 64 位整数:41 位时间戳 + 10 位机器号 + 12 位序列号。它有两个硬约束必须提前想清楚:时钟回拨怎么处理,机器号怎么分配。时钟回拨这事在小规模集群里一年可能碰上一两次,但碰上一次就是主键冲突。机器号分配如果靠手工配置,运维换机器忘了改配置,那也是事故。
带语义的复合编码就是我要重点讲的方案,适合需要人眼识别、需要按业务域分流的场景。
2.2 我推荐的字段拆解:前缀 + 域码 + 日期 + 节点 + 序列
具体结构我一般这么定:
REG + 业务域(2位) + 日期(6位, YYMMDD) + 节点号(3位) + 序列(5位)举例:REG0125061200100042,解读一下:
| 片段 | 长度 | 示例值 | 含义 |
|---|---|---|---|
| 固定前缀 | 3 | REG | 标识这是注册实体编号 |
| 业务域 | 2 | 01 | 01 用户,02 设备,03 应用,04 租户 |
| 日期 | 6 | 250612 | 2025 年 6 月 12 日 |
| 节点号 | 3 | 001 | 生成节点,000-999 |
| 序列 | 5 | 00042 | 该节点当日第 42 个 |
总长 19 位,定长,纯 ASCII,可以直接放进任何数据库的VARCHAR(19)或者CHAR(19)字段,不涉及字符集问题。
这个设计的几个好处值得说一下。日期在前意味着按 regID 排序约等于按注册时间排序,做冷热数据分离、按时间归档的时候特别好用,不用额外加时间索引。节点号让你在排查时能定位到是哪台机器生成的,日志对不上号的时候这条线索很值钱。序列 5 位上限是 99999,单节点单日接近 10 万,如果业务量再大就得扩位或者加一层“小时”字段,这个容量要提前算。
注意:日期用 YYMMDD 而不是 YYYYMMDD,是为了压长度,但代价是 100 年后会有歧义。如果你的系统生命周期预期超过 20 年,老老实实用 8 位年份。这不是开玩笑,有些做基础设施的系统真的要活那么久。
2.3 生成器的实现与并发安全
光有格式不够,生成器本身必须线程安全,而且序列重置的逻辑要写对。下面这段是我实际用过的 Python 版本,思路可以直接搬到 Java 或者 Go:
import datetime import threading class RegIdGenerator: def __init__(self, domain: str, node_id: int): if not (0 <= node_id <= 999): raise ValueError("节点号必须在 0-999 之间") if len(domain) != 2 or not domain.isdigit(): raise ValueError("业务域必须是两位数字") self.domain = domain self.node = f"{node_id:03d}" self._lock = threading.Lock() self._seq = 0 self._day = None def next(self) -> str: with self._lock: today = datetime.date.today().strftime("%y%m%d") if today != self._day: self._day = today self._seq = 0 self._seq += 1 if self._seq > 99999: raise OverflowError(f"节点 {self.node} 当日序列耗尽") return f"REG{self.domain}{today}{self.node}{self._seq:05d}"几个容易写错的地方,我逐个点一下。
第一,if today != self._day这个判断必须在锁内。如果放在锁外,两个线程可能同时判断为“新的一天”,一个把 seq 重置为 0,另一个紧接着也重置,然后两个都从 1 开始,直接撞号。
第二,序列必须先加再判还是先判再加要想清楚。我这里是先加,加到 100000 就抛异常。如果先判再加,边界条件容易写成>= 99999从而少用一个号,虽然不致命但很别扭。
第三,节点号为什么要限制在 0-999?因为格式里只留了 3 位。这条约束应该在启动时就校验,而不是等运行到第 1000 台机器才炸。格式的容量上限一定要在文档里写死,并且和服务端的校验正则保持一致,这是最容易两边不同步的地方。
如果要更强的可靠性,可以再加一位校验位,用前面所有字符做一次 mod 36 运算,映射到 0-9A-Z。这样人工抄录 regID 的时候能立刻发现输错了哪一位。校验位不是必须的,但凡是会被人手工输入、电话报读、纸质单据记录的编码,我都建议加。
2.4 格式校验与生命周期边界
服务端接收外部传入的 regID 时,第一件事永远是格式校验,别急着查库。正则这么写:
import re REG_ID_PATTERN = re.compile(r"^REG(\d{2})(\d{6})(\d{3})(\d{5})$") def parse_reg_id(value: str): m = REG_ID_PATTERN.match(value or "") if not m: return None domain, date_part, node, seq = m.groups() try: created = datetime.datetime.strptime(date_part, "%y%m%d").date() except ValueError: return None return { "domain": domain, "created": created, "node": node, "seq": int(seq), }注意这里对日期做了一次真实解析。很多实现只做正则匹配,REG0199999900100001这种日期不存在的号也能过,虽然不影响安全,但会让脏数据流进系统,后续做时间归档时排序全乱。多花一次strptime的代价,换数据干净,很值。
关于生命周期,有几个边界必须提前定好并且写进说明文档:
regID 可以回收吗?我的建议是不回收,永不复用。回收带来的问题远大于省下的那点编号空间。一个被注销的 regID,三年后如果被新实体复用,那三年内所有引用这个号的历史日志、审计记录、对账文件全部指向错误的对象。这种事一旦发生,修复成本是灾难级的。
regID 需要支持迁移吗?比如业务域从 01 改成 05。不要做这种事。真要改,就当作注销旧的、注册新的,中间做一个映射表过渡,但对外呈现永远是新的 regID。让标识不可变,是让它可靠的最便宜的方式。
要不要在 regID 里编码租户?我倾向于不要。租户信息是业务属性,放在数据库字段里,随时可查可改。塞进 ID 里会让 ID 变长、变复杂,而且租户合并、迁移的时候会非常痛苦。ID 只承载“唯一性”和“基本的分类信息”,别的都别塞。
3. token 的签发与校验:从生成到过期
token 这块内容,我觉得比 regID 更容易出安全问题,因为它直接关系到“能不能进门”。而实际项目里最常见的错误不是算法用错了,而是压根没想清楚 token 里该放什么、不该放什么。
有一次帮某团队看一个接入接口,他们的 token 是这么生成的:md5(userId + timestamp)。看着像那么回事,实际上任何人只要知道 userId 和大概的时间戳,几秒钟就能暴力枚举出合法 token。更糟的是他们的 userId 就是自增数字,从 1 开始。这不是安全漏洞,这是把门拆了。
所以这一节我想把 token 的生成、编码、签名、存储、刷新几个环节拆细讲,每个环节都给出可以直接抄的实现。
3.1 类型选型:不透明随机串还是自包含签名串
主流就两条路。
不透明随机串:服务端生成一串随机字符,把token -> (regID, 过期时间, 权限)的映射存在 Redis 或者数据库里。优点是服务端完全掌控,撤销就是删 key,简单粗暴;缺点是有状态,每次校验都要查一次存储,高并发下存储就是瓶颈。
自包含签名串(JWT 是典型代表):把 claims 编码进 token 本身,服务端用密钥验签,不需要查存储。优点是无状态、易水平扩展;缺点是无法即时撤销,只能靠短过期时间缓解。
我的选择逻辑是这样的:内部服务之间调用,用自包含;面向终端用户和设备的长会话,用不透明随机串。为什么?因为面向终端用户的场景,撤销需求是刚性的——用户点了“退出所有设备”,你就必须让他立刻退出去,这时候 JWT 的无状态优势反而变成负担,你得额外维护一个黑名单,等于又把状态加回来了,还不如一开始就用不透明串。
还有一种是两者的折中:短期的自包含 access token(15 分钟)+ 长期的不透明 refresh token(30 天)。这是我目前最推荐的组合,兼顾了性能和撤销能力,后面会讲具体怎么配合。
3.2 长度、字符集与熵的计算
随机串的长度不是拍脑袋定的,得算。假设用secrets.token_bytes(32)生成 32 字节,也就是 256 位熵,用 base64url 编码,每 3 字节编成 4 个字符,32 字节约等于 43 个字符(去掉 padding)。2^256 的空间,按现在和可预见未来的算力,暴力枚举是完全不可行的。
对比一下常见的错误做法:16 位十六进制字符 = 64 位熵。听着不少,但如果用random模块而不是secrets模块,熵可能只有 32 位甚至更低,因为random是伪随机,种子可预测。生成安全 token 必须用密码学安全的随机源:Python 用secrets,Java 用SecureRandom,Go 用crypto/rand,Node 用crypto.randomBytes。这条没有例外。
import base64 import secrets def new_opaque_token(nbytes: int = 32) -> str: raw = secrets.token_bytes(nbytes) return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")字符集用 base64url(A-Z a-z 0-9 - _),不用标准 base64,因为+和/在 URL 里需要转义,在表单里也可能出问题。base64url 是 URL 安全的,可以直接放在路径、查询串、请求头里都不用二次编码。
注意:生成的 token 长度要固定。有些实现会把长度也编进 token,或者允许变长,这会给攻击者提供额外信息(比如通过长度区分 token 类型)。固定 43 字符就是固定 43 字符,别搞花样。
3.3 签名、验签与比较时序
如果你走 JWT 或者自签方案,核心就是签和验。
签名算法的选择上,单服务内部用 HMAC-SHA256 就够了,密钥只有签发方和校验方知道。多方校验的场景(比如多个微服务都要验 token 但只应该有一个能签发)就得用非对称的,签发方持私钥签,校验方持公钥验。这时候选 Ed25519 比 RSA-2048 更好,密钥短、签名快、安全性足够。
验签的时候有个细节特别容易被忽略:比较签名必须用恒定时间比较。普通字符串==比较在第一个不同字符处就返回,攻击者能通过测量响应时间一点点猜出正确签名。虽然实战中通过网络抖动掩盖这个差异很难,但这是个零成本就能修的问题,没有理由不做。
import base64 import hashlib import hmac import json import time def _b64e(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") def _b64d(text: str) -> bytes: return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) def sign_claims(claims: dict, key: bytes) -> str: body = _b64e(json.dumps(claims, separators=(",", ":"), sort_keys=True).encode()) sig = hmac.new(key, body.encode("ascii"), hashlib.sha256).digest() return f"{body}.{_b64e(sig)}" def verify_token(token: str, key: bytes): try: body, sig_text = token.split(".") except ValueError: return None expect = _b64e(hmac.new(key, body.encode("ascii"), hashlib.sha256).digest()) if not hmac.compare_digest(expect, sig_text): return None try: claims = json.loads(_b64d(body)) except Exception: return None if int(claims.get("exp", 0)) <= int(time.time()): return None return claims这段代码有几个刻意的设计。sort_keys=True保证同样的 claims 序列化结果唯一,避免因为字典顺序不同导致签名对不上——这个坑我在联调时踩过,前后端各用各的 JSON 库,键顺序不一样,签名永远验证失败,排查了一整个下午。hmac.compare_digest是恒定时间比较。exp判断用<=而不是<,边界时刻直接判定过期,宁可严格一点。
3.4 存放位置:客户端和浏览器各有各的讲究
token 存哪儿,是个能吵起来的话题。
服务端对 token 的存储,永远是存哈希不存明文。用 SHA-256 存摘要就够了,因为 token 本身是高熵随机串,不需要加盐慢哈希(那是给密码用的)。校验时把收到的 token 哈希一遍去比对,这样即使数据库被拖走,攻击者也拿不到可用的 token。
客户端存储分场景。原生 App 用系统提供的安全存储(iOS 的 Keychain、Android 的 Keystore),别塞进 SharedPreferences 或者普通文件。浏览器场景复杂一些,localStorage会被 XSS 直接读走,HttpOnly + Secure + SameSite的 Cookie 能防住 XSS 读取,但要考虑 CSRF,需要配合 CSRF token 或者SameSite=Strict。我个人的偏好是:短期 access token 放内存,页面刷新就没了,用 refresh token 重新换;refresh token 放 HttpOnly Cookie。这样 XSS 拿不到 refresh token,攻击窗口被压缩到 access token 的有效期内。
不管哪种方案,有一条是共通的:token 绝不写日志。我见过太多系统在请求拦截器里把整个 header 打出来,日志集中采集之后,运维、测试、第三方都能看到。要打就只打前 6 位加后 4 位,中间用星号,用于排查时对比是不是同一个 token。
3.5 续期、刷新与强制下线
刷新机制的设计,核心是把“长期凭据”和“短期凭据”分开。
access token 有效期给 15 到 30 分钟,足够完成一批请求,泄露了损失也可控。refresh token 给 7 到 30 天,每次使用就轮换一次(rotate)——用旧的换新的,旧的同时作废。这一步很关键,因为如果 refresh token 被盗用,攻击者用一次之后,合法用户的 refresh token 就失效了,用户会发现“莫名其妙被登出”,这本身就是一次告警信号。更进一步,可以做刷新令牌族检测:如果某个已作废的 refresh token 被再次使用,说明存在并发使用,直接把这个用户的所有会话全部作废。
强制下线怎么做?如果 access token 是自包含的,那只能等它过期,15 分钟的时间窗口通常可以接受。如果业务要求秒级下线,就在校验环节加一次轻量检查:Redis 里存一个revoked:{regID}的版本号,token claims 里带上签发时的版本号,版本号对不上就拒绝。这个检查只读一个 key,成本很低,但让系统获得了即时撤销能力。我在几个对安全要求比较高的项目里用的是这个方案,实测 P99 增加不到 1 毫秒。
4. 端到端实操:一次注册到访问的完整流程
前面讲的都是零件,这一节把零件装成整机,走一遍完整链路。我把流程分成五个阶段:注册、签发、携带、校验、续期。每个阶段都给出请求/响应的具体形态和关键代码,你可以直接对着改。
4.1 阶段一:注册并获取 regID
注册请求是唯一一个不需要 token 的接口(否则就成了鸡生蛋)。它需要携带的是实体自身的属性,以及某种形式的前置验证——设备场景用设备指纹或者一机一密,应用场景用开发者密钥,用户场景用账号密码或者验证码。
请求大致长这样:
POST /api/v1/register Content-Type: application/json { "entityType": "device", "fingerprint": "sha256:9f2a...c7e1", "model": "M-2000", "firmware": "1.4.3", "nonce": "b7f3c1a9e0", "timestamp": 1730000000 }nonce和timestamp这两个字段不是凑数的。它们用来防重放:服务端检查 timestamp 与服务器时间偏差是否在 5 分钟内,同时把 nonce 存进一个有 TTL 的集合,同一个 nonce 第二次出现直接拒绝。没有这两个字段,攻击者抓到一个注册包就能无限次重复提交,造出一堆垃圾实体。
响应:
{ "regId": "REG0225061200100042", "createdAt": "2025-06-12T08:31:07Z", "status": "active", "secretHint": "首次签发,请立即保存" }如果注册流程会返回一个长期密钥(entity secret),那这个密钥只在这一次响应里出现,服务端只存哈希。客户端没保存好就只能走密钥重置流程。这条规则要在文档里用加粗写清楚,我见过太多客户端把密钥存在日志里然后来问我们“密钥怎么找回来”。
4.2 阶段二:换取 token
有了 regID 和密钥,就可以换 token 了。
POST /api/v1/token Content-Type: application/json { "grantType": "entity_secret", "regId": "REG0225061200100042", "secret": "…", "scope": "data:write data:read" }返回:
{ "accessToken": "eyJhbGciOi...", "tokenType": "Bearer", "expiresIn": 1800, "refreshToken": "rt_8Xk2…", "scope": "data:write data:read" }expiresIn给的是相对秒数而不是绝对时间戳,这个细节很重要。客户端和服务器的时钟几乎不可能完全同步,差个几分钟是常态。给绝对时间,客户端按本地时间算过期,要么提前失效要么延迟失效。给相对秒数,客户端用“收到响应的本地时刻 + expiresIn”算出本地过期时间,虽然仍有误差,但至少误差不累积。
校验逻辑我通常会加一层 scope 检查。token 里带 scope,接口上标注所需 scope,中间件比对。这样即使 token 泄露,攻击者也只能做 scope 范围内的事。给设备签发data:read就够了,就别给它data:write。
4.3 阶段三:业务请求携带
access token 放Authorization头,regID 放 body 或者路径里,两者用途完全不同。
POST /api/v1/device/data Authorization: Bearer eyJhbGciOi... Content-Type: application/json { "regId": "REG0225061200100042", "metrics": {"temp": 23.5, "hum": 61}, "ts": 1730000123, "seq": 8842 }这里有个必须做的校验,很多实现会漏:token 里的身份必须和 body 里的 regId 一致。如果不校验,A 设备拿着自己的合法 token,把 body 里的 regId 改成 B 设备,就能往 B 的数据流里写脏数据。这是最典型的越权,而且因为它“能用合法 token 调用成功”,很容易在测试阶段被漏掉。
def handle_device_data(request): claims = verify_token(extract_bearer(request), SIGNING_KEY) if claims is None: return error(401, "invalid_token") if claims["sub"] != request.json.get("regId"): # 身份与声明不符,直接拒绝并记录 audit_log.warning("regid_mismatch", token_sub=claims["sub"], body_regid=request.json.get("regId")) return error(403, "regid_mismatch") if "data:write" not in claims.get("scope", "").split(): return error(403, "insufficient_scope") return write_metrics(request.json)注意这里verify_token返回 None 就直接 401,不区分“签名错”“过期”“格式错”。为什么?因为对客户端来说这三种情况处理方式是一样的(重新获取 token),而对外暴露具体原因会给攻击者提供信息。细节差异记在服务端日志里就行。
4.4 阶段四:过期处理与刷新重试
客户端这块最容易写出 bug。常见错误是:token 过期,请求失败,客户端不刷新,直接重试,死循环打满服务端。
正确的骨架是:
import time class TokenHolder: def __init__(self, fetcher): self._fetcher = fetcher self._token = None self._expire_at = 0 def get(self) -> str: # 提前 60 秒视为过期,给网络延迟留余量 if self._token is None or time.time() >= self._expire_at - 60: self._refresh() return self._token def _refresh(self): resp = self._fetcher() self._token = resp["accessToken"] self._expire_at = time.time() + resp["expiresIn"] def call_with_retry(holder, do_request, max_retry=1): for attempt in range(max_retry + 1): try: return do_request(holder.get()) except UnauthorizedError: if attempt == max_retry: raise holder._refresh()几个要点。提前 60 秒刷新,别卡在过期那一刻,网络一来一回就超了。刷新必须加锁,多线程环境下如果多个线程同时发现过期,会同时发起刷新,服务端收到一堆刷新请求,如果做了 refresh token 轮换,后到的那些全都会失败,然后整个客户端就崩了。重试只做一次,避免死循环。
注意:刷新失败时的处理要明确。如果是网络错误,保留旧 token 稍后重试;如果是 401(refresh token 本身失效了),必须清空本地凭据,走完整的重新注册/登录流程。这两种情况混在一起处理,会导致网络抖动一次就把用户踢下线。
5. 联调排查实录:那些让人抓狂的问题
这一节是我这些年攒下来的问题清单。很多问题看着莫名其妙,但根因都很朴素,基本集中在格式、编码、时钟、缓存这四个方向。
5.1 常见问题速查表
| 现象 | 最可能的原因 | 快速验证方法 | 修复方向 |
|---|---|---|---|
| 签名验证一直失败,参数看着完全一样 | JSON 键顺序不一致,或空行/空格差异 | 两边打印待签名字符串的十六进制 | 序列化时sort_keys,禁止空格 |
| regID 入库后变成科学计数法 | 用了数值类型字段,或前端按 Number 解析 | 直接查库看字段类型 | 字段改VARCHAR(19),前端按字符串传 |
| token 刚签发就提示过期 | 服务器与校验方时钟不同步 | 两边date -u对比 | 部署 NTP,或改用相对有效期 |
| 部分请求 401,部分正常 | 多实例部署,签名密钥不一致 | 对比各实例的密钥指纹 | 密钥统一配置,或走配置中心 |
| 刷新后旧 token 还能用一段时间 | 无状态校验,没有撤销机制 | 用旧 token 直接请求 | 加版本号或黑名单 |
| base64 解码报错 | 用了标准 base64 而传输中被转义 | 检查是否有+/= | 统一改成 base64url 并去 padding |
| 重启服务后所有会话失效 | token 密钥每次启动随机生成 | 重启前后密钥是否相同 | 密钥持久化,从环境变量或密钥管理服务读 |
5.2 三个印象最深的案例
案例一:键顺序引发的血案。客户端用某语言的内置字典序列化,服务端用另一个语言的库,两边都是“把 claims 转成 JSON 再签名”。看着一样,实际键顺序不同,签出来的字节流就不同。解决方案就是前面代码里写的:签名前统一sort_keys=True并去掉所有多余空白。更好的做法是不要自己造这个轮子,用成熟的 JWT 库,这类问题标准库早就处理过了。自己实现一遍的唯一价值是理解原理,生产环境别用。
案例二:regID 精度丢失。早期版本的 regID 是 18 位纯数字,前端用 JavaScript 接收,超过Number.MAX_SAFE_INTEGER(9007199254740991,16 位)之后末尾几位直接被抹成 0。表现为:设备注册成功,但拿到的 regID 查不到设备。这个问题的隐蔽之处在于,它只在数字足够大的时候出现,测试环境数据少,一直没暴露。后来加了字母前缀,强制走字符串,问题消失。凡是可能超过 16 位的标识,一律用字符串传输,这条我写进了团队规范。
案例三:时钟回拨导致 token 提前过期。某台机器的 NTP 服务配置有问题,每隔一段时间会把系统时间往回拨几分钟。结果这台机器上签发的 token 一出生就是“过期”状态。排查的时候看日志全是“token 已过期”,但客户端明明刚拿到。最后定位到时间源。修复方式是所有时间比较统一走一个单调时钟接口,并且在签发时记录相对 TTL,校验时用签发时刻的单调时间 + TTL来判断。这个改动不大,但彻底根治了时钟漂移类问题。
5.3 日志与可观测性怎么加
token 和 regID 相关的日志,我一般要求这几个字段必须落:
trace_id:串联整条链路reg_id:业务归属维度token_fp:token 的指纹,取 SHA-256 前 8 位,用于判断是不是同一个 token,但不泄露原文auth_result:ok/expired/bad_sig/scope_denied/regid_mismatchclient_ip、user_agent:做异常检测用
有了token_fp,排查“用户说一直登录失败”这类问题时效率提升特别明显:看日志里是不是同一个指纹反复出现 bad_sig,就能判断是客户端缓存了坏 token,还是服务端密钥配错了。没有这个字段,你只能让用户重新复现一遍。
另外建议加一个监控指标:401 比例按原因分组。正常情况下expired应该占绝大多数,bad_sig应该接近零。如果bad_sig突然上升,八成是密钥轮换出了问题或者有人在扫接口。这个指标比单纯看 5xx 更能提前发现问题。
6. 安全与工程细节的红线清单
最后一节,我把那些“知道但容易忘”的点集中列一下。每一条都是我在真实项目里见过出问题或者差点出问题的。
token 和 regID 的边界不能模糊。前面反复说了,这里再强调一次:鉴权只认 token,归属只认 regID。代码 review 时看到有人拿 regID 去判断权限,直接打回去。
密钥轮换要能做到不停机。签名密钥不能是永久的一把。合理的做法是维护一个密钥列表,每条带kid和状态(active / deprecated / retired)。签发用 active 的,校验时按kid找到对应密钥。轮换的时候把新密钥设为 active,旧的标为 deprecated 但仍可校验,等所有旧 token 过期之后再 retired。这样整个轮换过程对用户完全无感。
注册接口必须限流。注册是会写库的操作,而且往往不需要 token,是最容易被刷的入口。按 IP、按设备指纹、按时间窗口三个维度都要限。我一般设的阈值是:单 IP 每分钟 10 次,单指纹每小时 5 次,全局按业务容量设定。超限返回 429 并带Retry-After。
scope 要最小化。签发 token 时按需给权限,别图省事给个*。设备只需要上报数据的,就别给它读配置的权限。这个原则在出事后能显著缩小影响面。
敏感操作要二次验证,不能只靠 token。改密码、重置密钥、删除实体这类操作,即使 token 合法也应该要求额外的验证(旧密码、短信、设备确认)。因为 token 可能已经泄露,只是攻击者还没动手。
多环境严格隔离。开发、测试、生产的 regID 空间和签名密钥必须完全分开。最怕的就是测试环境拿着生产的密钥在跑,某次误操作直接往生产库写了数据。我的做法是把环境标识编进密钥的前缀里,启动时校验当前环境与密钥前缀是否匹配,不匹配直接启动失败。这个检查五秒钟能实现,能挡住大事故。
定期清理过期数据,但审计日志不能删。token 记录过期就可以清,但每次签发、撤销、鉴权失败的事件要留够时间。合规上一般是半年到一年,具体看业务要求。审计日志里不能有 token 明文,存指纹就行。
文档要和代码同源。这条不是技术问题,但最容易被忽视。regID 的格式说明、token 的字段定义,如果文档和代码各写各的,三个月后必然对不上。我现在的做法是把校验正则和字段定义维护在一份 schema 文件里,文档生成和代码校验都从这份文件来。前期多花半天,后期省无数扯皮。
我个人在实际操作中的体会是,这套东西的复杂度不在于单个技术点有多深,而在于边界要划清楚并且所有人认同。regID 是身份,token 是通行证,注册是发身份证,签发是发门禁卡,刷新是续期,撤销是挂失。把这几个动作和它们对应的数据流画在同一张纸上,团队里每个人都能指着说清楚“这一步动的是哪个”,后面的实现就只是体力活了。真正拖慢项目的从来不是不会写代码,而是有人在第三步的时候问“这个字段是不是 token 的哈希”,然后会议室安静十秒钟。