尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

一次性授权令牌设计拆解:防重放、签名校验与车联网指令安全实践

一次性授权令牌设计拆解:防重放、签名校验与车联网指令安全实践 1. 从一次“取车失败”说起token-1002 到底是什么前阵子我们线上有一个租车订单频繁报“token校验失败”用户已经支付成功、人也站在门店门口结果手机端一直弹“操作超时请重新打开订单”。排查到最后问题出在一个叫 token-1002 的短时授权令牌上。这个编号在租车宝的代码里并不起眼但它负责的恰恰是整个取车环节里最敏感的一次操作授权。所谓 token-1002是租车宝系统内部对“订单取车操作令牌”的一个算法编号。它既不是用户登录用的 session也不是跨端同步用的刷新令牌而是专门为“用户确认取车→门店终端解锁车辆→系统锁定订单”这一条业务链生成的一次性授权凭证。简单说用户在手机端点“确认取车”时客户端会拿着这个 token 去请求车辆解锁接口服务端验证通过后才允许后续的车控指令下发。这类 token 和常见的 JWT 还不完全一样。JWT 是一个通用格式里面可以塞各种声明谁签发谁校验都比较自由而 token-1002 是租车宝内部根据业务场景定制的一套签名协议它把订单编号、车辆编号、业务状态、时间窗口、随机数全部绑定在一起目的就两个防止请求被伪造防止请求被重放。现实中很多团队会直接用现成的 JWT 库但租车场景里车辆解锁指令一旦被重放车就可能被非法开走所以租车宝选择自研这套专用算法把安全边界控制得更细。这篇文章适合谁看如果你在做 IoT 设备的指令授权、短时业务令牌设计、或者任何“一次操作只能生效一次”的授权场景token-1002 的分析思路都可以直接借鉴。我会把它从设计动机、算法结构、签名校验流程、到线上踩坑和排查方法完整拆开讲。不涉及租车宝内部敏感密钥所有代码都是按同型逻辑重新写的示例。2. 整体设计拆解为什么“取车”需要一套独立的令牌算法2.1 为什么不用现成的 Session 或 JWT先回答一个很自然的问题租车宝又不是没有登录态用户登录后服务端已经发了 session为什么取车时不直接拿 session 去解锁车辆这就要说到车控指令的特点了。车控指令开门、点火、锁车和普通接口请求不一样它属于高敏感、短时、不可逆的操作。一旦下发成功车辆物理状态就会改变没有办法通过业务逻辑“撤销”。如果拿长期有效的登录态去发起车控请求会有几个问题第一登录态存续时间长很多 App 的 session 能保持 7 到 30 天期间只要 token 泄露任何人都能操控这辆车第二普通的 session 校验只验证“你是你”不能验证“你正在操作的订单属于你且处于可取车状态”第三车控通道经过车联网网关如果网关和服务端之间只靠 session 通信那么网关一旦被拖库所有车辆的解锁密钥都会暴露。所以租车宝的思路是把“用户身份”和“操作授权”两件事彻底分开。登录态只解决“你是谁”而 token-1002 解决的是“你被允许对哪辆车执行哪个动作、在什么时间窗口内有效、只能用一次”。这是一次典型的最小权限设计哪怕取车 token 泄露了攻击者也只能在极短时间窗口内对某一辆车执行一次解锁操作无法扩大影响面。2.2 token-1002 的设计目标在设计 token-1002 时团队定了四个硬指标绑定订单上下文。token 必须携带订单编号、车辆编号、门店编号服务端校验时会把 token 内的上下文和数据库订单状态做交叉比对。防止有人拿 A 订单的令牌去解锁 B 车。短时效。从用户点击“确认取车”到车辆解锁整个过程一般不超过 5 分钟。所以 token 有效期就设 5 分钟过期后必须重新走一次业务确认流程。这能最大限度压缩重放攻击的时间窗口。一次性使用。token-1002 一旦被车联网网关成功消费立即在缓存中标记为已使用第二次携带相同的 token 过来直接拒绝。这解决的是“录屏重放”“抓包重放”这类攻击。离线可验。车联网网关和云端主站之间并不总是强一致的尤其在地下停车场、偏远郊区网络链路会抖动。token-1002 的签名算法设计成网关只要持有公钥或共享密钥就能本地完成签名验证不需要每次都回源数据库查询订单状态。这个设计和 JWT 的离线验签思路是一致的但字段和校验逻辑更贴合租车业务。2.3 相比标准 JWT 的取舍这里补一个对比表方便你理解为什么没有直接选 JWT维度标准 JWTtoken-1002声明字段通用由业务自行定义固定结构强制绑定订单/车辆/时间窗口签名算法HS256/RS256 等内部固定为 HMAC-SHA256 密钥版本号一次性默认不支持强制一次性配合缓存记录消费状态时效通常偏长小时/天级严格 5 分钟校验位置任意持有密钥的服务服务端 车联网网关双重校验上下文校验靠业务代码自行处理校验逻辑内建默认比对订单状态机不是说 JWT 不好而是 JWT 的灵活性在车控场景里反而容易埋坑。业务方拿到 JWT 后往往要自己再去查一遍订单状态查漏了就出事故。token-1002 把“必须查订单状态”直接焊死在算法里少一步都不给过。安全设计很多时候不是做得不够多而是给业务方留了太多“可选项”可选项就意味着可犯错。3. 核心细节解析token-1002 的算法结构与参数逻辑3.1 token 的数据结构token-1002 不是标准的 JWT 三段式但它借鉴了同样的“头部 载荷 签名”思路。整体按 Base64Url 编码用点号分隔成三段看起来和 JWT 很像但内部字段是租车宝自己定义的。第一段是头部包含算法标识和密钥版本号{ alg: HS256, kid: key-v3, typ: rtb-token-1002 }kid是密钥版本标识。线上会同时存在多个版本的密钥签名时用当前最新的版本校验时根据kid找到对应密钥。这个设计是为了支持密钥轮换——每季度强制换一次签名密钥旧 token 在有效期内还能验过了有效期自然失效不会出现换密钥导致大规模业务中断。第二段是载荷承载业务上下文{ order_id: RB20250608120001, vehicle_id: SH-A8B32, shop_id: SH01, action: unlock, iat: 1749364800, exp: 1749365100, window: 1749364800, nonce: 7f3a9c2e }每个字段都不是随便定的我逐个说。order_id就是订单号校验时用来查订单当前状态vehicle_id是车辆唯一编码防止拿这个令牌去请求其他车辆shop_id是门店编号车联网网关在做区域路由时需要用它决定把指令转发到哪片区域的服务节点action是动作类型这里固定为unlock将来如果还车、续租也要走类似令牌可以把action扩成lock、extend等iat和exp是签发时间和过期时间Unix 时间戳window是客户端回传的当前时间戳用于服务端校准时钟偏差nonce是随机字符串每次生成都不一样配合缓存做一次性消费标记。第三段是签名计算方法是对“头部 点号 载荷”整体做 HMAC-SHA256然后 Base64Url 编码。签名用的密钥从密钥管理服务KMS拉取进程启动时加载到内存定期刷新。3.2 时间窗口与防重放机制为什么 window 比 exp 更关键这是 token-1002 里最容易被忽略的一个设计。很多系统只设exp过期时间但exp校验有个天然缺陷校验方依赖自己的本地时钟。如果车联网网关的时钟慢了 30 秒一个已经过期的 token 在网关视角看起来还是有效的反过来如果服务端时钟快了新签发的 token 直接被拒。分布式系统里时钟偏移是常态不是异常。token-1002 的做法是加了一个window字段。客户端在发起取车请求时会带上自己设备当前的时间戳放进window服务端校验时先算|服务端当前时间 - window|如果超过 120 秒直接拒绝。这一步把“绝对时间校验”变成了“相对时间校验”钝感了时钟偏移带来的影响。exp仍然保留但它的作用从“精确拦截过期请求”降级成了“粗粒度兜底”。真正精细的时效控制靠window 时间差阈值完成。防重放则靠nonce和缓存配合。具体流程是服务端生成 token 时把nonce写进 Redis设置过期时间和 token 有效期一致车联网网关消费 token 后调用一个统一的“标记消费”接口往 Redis 里写入consumed:nonce下次再有相同nonce的 token 请求进来网关先查 Redis发现已经消费过就返回“token repeated”。这里要注意一个细节标记消费和指令下发必须是原子操作否则车已经解锁了消费标记还没写进去重放攻击就有空子可钻。我们线下的实现是把“标记消费”和“指令下发”放在同一个事务消息里先写标记再发指令标记写失败就取消下发。3.3 签名密钥管理一个最容易翻车的角落签名密钥管理是 token 算法里最容易翻车、又最容易被忽视的部分。很多团队把密钥硬编码在配置文件里甚至直接写在代码仓库这在我看来等于没做签名。token-1002 的密钥管理有几个硬性要求第一密钥必须按环境隔离。测试环境、预发环境、生产环境各用各的密钥测试密钥泄露了也不影响生产。第二密钥必须支持轮换。线上每季度轮换一次轮换时新旧密钥并行保留 7 天保证存量 token 能正常校验。第三密钥不能出现在任何日志里。我们踩过这个坑有一次调试时不小心把密钥明文打到了日志平台虽然内网访问有权限控制但还是在当天就做了密钥轮换——凡是可能泄露的密钥一律默认已泄露立刻换。密钥轮换的实现也不复杂核心就是kid字段。签发时用当前最新的密钥版本key-v3校验时通过kid去内存缓存里找对应密钥如果kid对应的密钥不存在有两种可能要么 token 是伪造的要么密钥已经下线。我们处理方式是直接拒绝然后告警排查。正规的做法是有一个“密钥当前版本”的接口签发前先拉一次拿到的版本号写进 token 头部。4. 实操过程与核心实现从生成到消费的完整链路4.1 服务端生成 token-1002 的标准流程生成 token-1002 的入口在订单服务里用户点击“确认取车”按钮后触发。整个生成流程可以拆成五个步骤我结合项目里的伪代码来讲。第一步校验订单状态。这一步极其关键生成 token 之前必须确认订单处于“待取车”状态。为什么因为如果用户已经取过车了订单状态已经变成“使用中”这时候再生成一个解锁 token 就属于非法操作。防止的方式是查订单状态机只有PENDING_PICKUP状态才允许继续。这一步放在生成侧而不是校验侧是为了尽早拦截减少无效 token 的产生。def generate_pickup_token(order, vehicle, device_timeNone): # 第一步校验订单状态只有待取车才能生成 if order.status ! OrderStatus.PENDING_PICKUP: raise BizException(当前订单状态不允许取车操作) # 第二步绑定业务上下文 payload { order_id: order.order_id, vehicle_id: vehicle.vehicle_id, shop_id: order.shop_id, action: unlock, iat: int(time.time()), exp: int(time.time()) TOKEN_TTL_SECONDS, window: device_time or int(time.time()), nonce: uuid.uuid4().hex[:16] } # 第三步从 KMS 拉取当前最新密钥版本 kid, secret get_current_signing_key() # 第四步构造头部和签名 header {alg: HS256, kid: kid, typ: rtb-token-1002} header_b64 base64url_encode(json.dumps(header)) payload_b64 base64url_encode(json.dumps(payload)) signing_input f{header_b64}.{payload_b64} signature hmac_sha256(secret, signing_input) # 第五步nonce 写入 Redis标记未消费状态 redis.setex(ftoken:nonce:{payload[nonce]}, TOKEN_TTL_SECONDS, unused) return f{signing_input}.{base64url_encode(signature)}注意第五步nonce写入 Redis 时 initial 值是unused等网关消费后会改成consumed。这个标记承担了两层作用一是防重放二是排查问题。如果线上出现“token 过期但业务显示已成功”的矛盾查这个 Key 的当前值就能定位——是没消费还是消费了但没回写。4.2 车联网网关的本地校验逻辑车联网网关收到解锁请求后拿到的是客户端透传的 token-1002。网关不会直接请求云端主站验证而是本地完成签名校验和上下文校验。校验逻辑分四步顺序不能乱第一步解析头部拿到kid。第二步用kid从本地密钥缓存中找到对应密钥找不到就拒绝。第三步重算签名和 token 携带的签名比对不一致就拒绝。第四步校验载荷字段exp是否大于当前时间、action是否为unlock、vehicle_id是否和请求路径上的车辆一致。def verify_token(token, target_vehicle_id): header_b64, payload_b64, sig_b64 token.split(.) header json.loads(base64url_decode(header_b64)) payload json.loads(base64url_decode(payload_b64)) # 找密钥 secret key_cache.get(header[kid]) if secret is None: raise AuthException(未知的签名密钥版本) # 重算签名 expected_sig hmac_sha256(secret, f{header_b64}.{payload_b64}) if not hmac_compare(expected_sig, sig_b64): raise AuthException(签名校验失败) # 校验时效 if time.time() payload[exp]: raise AuthException(token 已过期) # 校验车辆绑定 if payload[vehicle_id] ! target_vehicle_id: raise AuthException(车辆编号与 token 不匹配) # 校验一次性 if redis.get(ftoken:nonce:{payload[nonce]}) ! unused: raise AuthException(token 已被消费) return payload校验有个细节签名比对必须用常量时间比较函数如hmac.compare_digest不能用普通的。因为普通字符串比较在遇到不匹配时会提前返回时间差异可以被攻击者测量出来进而用来猜测签名。这是教科书级的侧信道问题但实际项目里真的有一半的团队没用常量时间比较。4.3 一次性消费标记的原子性处理上面校验了开始时的 nonce 状态但真正的重点在消费标记的原子性。网关校验完 token 后需要做的事情有两件把 Redis 里的 nonce 标记为consumed然后下发车辆解锁指令。这两件事有严格顺序但中间有失败的可能。如果先下发指令、后标记消费一旦标记消费时 Redis 超时指令已经发下去了nonce 还是unused攻击者重放同一个 token网关再次校验通过车辆被二次解锁。如果先标记消费、后下发指令一旦指令下发失败nonce 已经是consumed用户拿着一个合法 token 却没法再试一次业务上就说“明明没解锁成功系统却说 token 已失效”。我们最终的方案是两阶段先调用一个原子脚本把 nonce 标记为consumed同时记录一次“消费中”的日志然后下发指令如果指令下发成功结束如果指令下发失败走补偿逻辑把 nonce 回滚为unused允许客户端重试。这个补偿逻辑有 10 秒的窗口期超时就认为指令真的失败了让用户重新走一次取车确认流程。从业务数据来看真正需要走重试流程的用户比例不到千分之一但至少不会出现“车没解锁token 又报废”的窘境。Redis 的原子标记用 Lua 脚本实现if redis.call(GET, KEYS[1]) unused then return redis.call(SET, KEYS[1], consumed) else return nil end为什么用 Lua因为GET和SET是两个操作中间有竞态窗口两个请求同时进来都读到unused然后都去触发解锁指令就重放了。Lua 脚本在 Redis 里是原子执行的可以保证“先检查后设置”在并发下也安全。4.4 与订单状态机的联动token-1002 不是独立运行的它在整个取车链路里扮演的参数需要业务侧持续推进。代客取车场景如果司机帮忙取车token 里要增加pickup_by字段标记代取人身份企业长租场景可能要支持多辆车一次性取车那么 token 就要设计成支持批量操作。协议先行实现跟随是 token 这类安全模块迭代时的基本原则。单就目前的 token-1002 实现来说它在日志可观测性上还有提升空间我们后续计划在 token 里加一个trace_id做全链路追踪这样国内排查时就不用靠正则从日志里捞。另一个方向是把签名算法从 HMAC 升级成 Ed25519 非对称签名这样网关只需要持有公钥即使网关被攻破密钥也不会泄露。这些都是后话但方向是明确的安全模块的迭代永远没有终点防线要跟着攻击手段一起长。
返回列表