登录安全三剑客:Token机制详解

📅 发布时间:2026/9/13 13:05:29
登录安全三剑客:Token机制详解
序幕一个现实世界的类比在讲Token之前让我先讲一个故事。你去一家高级酒店住宿。┌─────────────────────────────────────────────────┐ │ │ │ 你走进酒店大堂。 │ │ │ │ 前台小姐您好请出示身份证。 │ │ │ │ 你掏出身份证。前台核实了你的身份。 │ │ 这就是登录——用账号密码证明你是谁 │ │ │ │ 前台不会把你的身份证留下。 │ │ 她给了你三样东西 │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ │ │ │ │ 房卡access_token │ │ │ │ 用来进出房间、使用健身房、游泳池 │ │ │ │ 有效期24小时 │ │ │ │ 过期后刷不开任何门 │ │ │ │ │ │ │ │ 续住凭证refresh_token │ │ │ │ 房卡过期后拿这个去前台换新房卡 │ │ │ │ 不需要再出示身份证 │ │ │ │ 有效期30天 │ │ │ │ │ │ │ │ 消费手环pay_token │ │ │ │ 在酒店内消费用这个 │ │ │ │ 餐厅点餐、商店购物、SPA预约 │ │ │ │ 结账时扫手环不用每次掏信用卡 │ │ │ │ │ │ │ └──────────────────────────────────────┘ │ │ │ │ 三样东西三个用途。 │ │ 缺一不可但绝不能混用。 │ │ │ │ 你不能拿房卡去餐厅买单。 │ │ 你不能拿消费手环去开房门。 │ │ 你不能拿续住凭证去游泳池。 │ │ │ └─────────────────────────────────────────────────┘这就是SDK中三个Token的关系。现在让我们一个一个深入了解。第一章access_token——你的通行房卡access_token是什么 一句话它是用户登录后获得的身份凭证。 用户用账号密码或第三方登录完成认证后 服务器不会让你每次请求都带上密码。 那太危险了——密码在网络上传来传去迟早被截获。 服务器给你一个access_token。 之后的每次请求你只需要带上这个token。 服务器看到token就知道你是谁。 ┌─────────────────────────────────────────────────┐ │ │ │ 登录过程 │ │ │ │ 客户端 服务器 │ │ │ │ │ │ │ 我是张三密码123456 │ │ │ │ ─────────────────────────→ │ │ │ │ │ │ │ │ 验证密码... ✅ │ │ │ 生成token │ │ │ │ │ │ │ access_token: abc123... │ │ │ │ refresh_token: xyz789... │ │ │ │ pay_token: pay456... │ │ │ │ ←───────────────────────── │ │ │ │ │ │ │ │ 从此以后密码再也不传输了 │ │ │ │ │ │ │ │ │ │ │ 后续请求 │ │ │ │ 客户端 服务器 │ │ │ │ │ │ │ 查询我的角色信息 │ │ │ │ access_token: abc123... │ │ │ │ ─────────────────────────→ │ │ │ │ │ │ │ │ 验证token... ✅ │ │ │ 这是张三的token │ │ │ 查询张三的角色信息 │ │ │ │ │ │ │ { name:大剑士, level:60 }│ │ │ │ ←───────────────────────── │ │ │ │ │ │ │ │ └─────────────────────────────────────────────────┘ access_token的关键特性 ┌─────────────────────────────────────────────────┐ │ │ │ 1. 短寿命 │ │ │ │ access_token的有效期通常很短 │ │ - 2小时常见 │ │ - 24小时宽松 │ │ - 7天非常宽松 │ │ │ │ 为什么要短 │ │ │ │ 想象你的房卡被小偷捡到了。 │ │ 如果房卡永远有效小偷可以永远进出你的房间。 │ │ 如果房卡24小时后过期小偷最多用24小时。 │ │ 如果房卡2小时后过期损失更小。 │ │ │ │ access_token被盗 房卡被捡走。 │ │ 有效期越短风险窗口越小。 │ │ │ │ │ │ 2. 高频使用 │ │ │ │ 几乎每个API请求都要带上access_token │ │ - 查询用户信息 │ │ - 获取好友列表 │ │ - 上传游戏存档 │ │ - 获取排行榜 │ │ - 发送聊天消息 │ │ │ │ 它是最常用的token。 │ │ 每次和服务器说话都要亮出这张房卡。 │ │ │ │ │ │ 3. 不可续期 │ │ │ │ access_token过期了就是过期了。 │ │ 不能用旧的access_token换新的access_token。 │ │ 你需要用另一个东西来换——refresh_token。 │ │ │ └─────────────────────────────────────────────────┘ 代码示例——怎么用access_token ┌─────────────────────────────────────────────────┐ │ │ │ // 登录成功后保存token │ │ void OnLoginSuccess(LoginResponse response) │ │ { │ │ // 保存三个token │ │ m_AccessToken response.access_token; │ │ m_RefreshToken response.refresh_token; │ │ m_PayToken response.pay_token; │ │ │ │ // 记录access_token的过期时间 │ │ m_TokenExpireTime Now() response.expires_in;│ │ } │ │ │ │ │ │ // 每次请求API时带上access_token │ │ void GetUserProfile() │ │ { │ │ HttpRequest request; │ │ request.url https://api.sdk.com/user/profile;│ │ request.headers[Authorization] │ │ Bearer m_AccessToken; │ │ │ │ // ↑ Bearer是一种标准格式 │ │ // 意思是我带着一个令牌 │ │ │ │ Http.Send(request, OnProfileResponse); │ │ } │ │ │ └─────────────────────────────────────────────────┘第二章refresh_token——你的续住凭证access_token过期了。怎么办 方案A让用户重新登录。 您的房卡过期了请回大堂重新出示身份证。 用户正在打Boss。 弹出一个登录框。 输入账号密码。 Boss把他打死了。 用户卸载了游戏。 这是最糟糕的用户体验。 方案B用refresh_token静默续期。 您的房卡过期了请出示续住凭证我给您换一张新房卡。 用户什么都不知道。 游戏在后台自动用refresh_token换了一个新的access_token。 用户继续打Boss。 一切无缝衔接。 这才是正确的做法。 ┌─────────────────────────────────────────────────┐ │ │ │ 续期过程 │ │ │ │ 客户端 服务器 │ │ │ │ │ │ │ 查询排行榜 │ │ │ │ access_token: abc123... │ │ │ │ ─────────────────────────→ │ │ │ │ │ │ │ │ 验证token... ❌ │ │ │ 这个token过期了 │ │ │ │ │ │ │ 错误401 Unauthorized │ │ │ │ ←───────────────────────── │ │ │ │ │ │ │ │ │ │ │ 客户端检测到401自动发起续期 │ │ │ │ │ │ 用refresh_token换新token │ │ │ │ refresh_token: xyz789...│ │ │ │ ─────────────────────────→ │ │ │ │ │ │ │ │ 验证refresh_token... ✅ │ │ │ 生成新的token │ │ │ │ │ │ │ 新access_token: def456..│ │ │ │ 新refresh_token: uvw321.│ │ │ │ ←───────────────────────── │ │ │ │ │ │ │ │ │ │ │ 用新token重新发起原来的请求 │ │ │ │ │ │ 查询排行榜 │ │ │ │ access_token: def456... │ │ │ │ ─────────────────────────→ │ │ │ │ │ │ │ │ 排行榜数据 │ │ │ │ ←───────────────────────── │ │ │ │ │ │ │ │ 用户全程无感知。 │ │ │ │ │ └─────────────────────────────────────────────────┘ refresh_token的关键特性 ┌─────────────────────────────────────────────────┐ │ │ │ 1. 长寿命 │ │ │ │ refresh_token的有效期通常很长 │ │ - 30天常见 │ │ - 90天宽松 │ │ - 永不过期某些SDK │ │ │ │ 为什么可以长 │ │ 因为refresh_token只在续期时使用一次。 │ │ 不像access_token每个请求都要带。 │ │ 暴露在网络上的次数少被截获的概率低。 │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ │ │ │ │ access_token │ │ │ │ 每天传输100次。暴露100次。 │ │ │ │ → 有效期短2小时降低风险。 │ │ │ │ │ │ │ │ refresh_token │ │ │ │ 每天传输1次续期时。暴露1次。 │ │ │ │ → 有效期长30天方便用户。 │ │ │ │ │ │ │ │ 这是安全性和便利性的平衡。 │ │ │ │ │ │ │ └──────────────────────────────────────┘ │ │ │ │ │ │ 2. 一次性使用通常 │ │ │ │ 很多SDK的refresh_token是用一次就换一个。 │ │ 你用旧的refresh_token换新token时 │ │ 服务器会同时返回一个新的refresh_token。 │ │ 旧的refresh_token立即作废。 │ │ │ │ 为什么 │ │ │ │ 如果refresh_token被盗了 │ │ 小偷用它换了新token。 │ │ 旧的refresh_token作废了。 │ │ 你下次续期时发现refresh_token无效。 │ │ 你知道出事了。 │ │ 你可以重新登录让小偷的token也失效。 │ │ │ │ 这叫refresh_token轮换Rotation。 │ │ 是一种安全机制。 │ │ │ │ │ │ 3. 安全存储 │ │ │ │ refresh_token比access_token更敏感。 │ │ access_token过期了就没用了。 │ │ refresh_token被盗了攻击者可以持续获取新token。 │ │ │ │ 存储建议 │ │ ┌──────────────────────────────────────┐ │ │ │ │ │ │ │ ❌ 不要存在 │ │ │ │ - URL参数中 │ │ │ │ - 日志文件中 │ │ │ │ - 明文配置文件中 │ │ │ │ - 前端JavaScript的全局变量中 │ │ │ │ │ │ │ │ ✅ 应该存在 │ │ │ │ - 加密的本地存储中 │ │ │ │ - 系统钥匙串Keychain / KeyStore │ │ │ │ - 服务器端的安全Session中 │ │ │ │ │ │ │ └──────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────┘ 代码示例——自动续期机制 ┌─────────────────────────────────────────────────┐ │ │ │ // 封装一个智能请求函数 │ │ // 自动处理token过期和续期 │ │ │ │ void SmartRequest(HttpRequest request, │ │ Callback onSuccess, │ │ Callback onFail) │ │ { │ │ // 第1步检查token是否快过期了 │ │ if (IsTokenExpiringSoon()) │ │ { │ │ // 提前续期不等到真正过期 │ │ RefreshTokenAsync(() { │ │ // 续期成功后再发请求 │ │ DoRequest(request, onSuccess, onFail);│ │ }); │ │ return; │ │ } │ │ │ │ // 第2步正常发请求 │ │ DoRequest(request, onSuccess, onFail); │ │ } │ │ │ │ │ │ void DoRequest(request, onSuccess, onFail) │ │ { │ │ request.headers[Authorization] │ │ Bearer m_AccessToken; │ │ │ │ Http.Send(request, (response) { │ │ if (response.code 401) │ │ { │ │ // token过期了尝试续期 │ │ RefreshTokenAsync(() { │ │ // 续期成功用新token重试 │ │ DoRequest(request, │ │ onSuccess, onFail); │ │ }, │ │ () { │ │ // 续期也失败了 │ │ // refresh_token也过期了 │ │ // 只能让用户重新登录 │ │ ShowLoginScreen(); │ │ }); │ │ } │ │ else if (response.code 200) │ │ { │ │ onSuccess(response); │ │ } │ │ else │ │ { │ │ onFail(response); │ │ } │ │ }); │ │ } │ │ │ │ │ │ bool IsTokenExpiringSoon() │ │ { │ │ // 提前5分钟续期避免刚好过期的尴尬 │ │ return Now() m_TokenExpireTime - 300; │ │ } │ │ │ └─────────────────────────────────────────────────┘第三章pay_token——你的消费手环pay_token是三兄弟中最特殊的一个。 它只做一件事证明这个人可以花钱。 ┌─────────────────────────────────────────────────┐ │ │ │ 为什么支付需要单独的token │ │ │ │ 用access_token不行吗反正它也能证明身份。 │ │ │ │ 不行。原因有三 │ │ │ │ │ │ 原因1权限隔离 │ │ │ │ access_token的权限是查看和操作游戏数据。 │ │ pay_token的权限是花钱。 │ │ │ │ 这两个权限必须分开。 │ │ │ │ 想象一个场景 │ │ 你的access_token被黑客截获了。 │ │ 如果access_token也能支付—— │ │ 黑客可以用你的账号买10000个钻石。 │ │ 你的钱没了。 │ │ │ │ 但如果支付需要单独的pay_token—— │ │ 黑客有access_token只能看你的游戏数据。 │ │ 想花钱没有pay_token门都没有。 │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ │ │ │ │ access_token被盗 │ │ │ │ 黑客能看你的角色信息 → 损失小 │ │ │ │ │ │ │ │ pay_token被盗 │ │ │ │ 黑客能用你的钱买东西 → 损失大 │ │ │ │ │ │ │ │ 分开存放分开传输分开管理。 │ │ │ │ 一个被盗不影响另一个。 │ │ │ │ │ │ │ └──────────────────────────────────────┘ │ │ │ │ │ │ 原因2支付流程需要额外验证 │ │ │ │ pay_token通常不是登录时直接给的。 │ │ 它可能需要额外的步骤才能获取 │ │ │ │ - 用户确认支付意愿点击购买按钮 │ │ - 二次验证输入支付密码、指纹、面容 │ │ - 服务器验证订单合法性 │ │ - 然后才下发pay_token │ │ │ │ 这保证了每一笔支付都是用户主动发起的。 │ │ 不会出现后台偷偷扣钱的情况。 │ │ │ │ │ │ 原因3支付渠道的要求 │ │ │ │ 游戏SDK通常对接多个支付渠道 │ │ Apple IAP、Google Play、微信支付、支付宝... │ │ │ │ 每个渠道有自己的安全要求。 │ │ pay_token可能包含渠道特定的信息 │ │ - 订单号 │ │ - 商品ID │ │ - 金额 │ │ - 渠道签名 │ │ │ │ 这些信息和用户身份无关 │ │ 和这笔交易有关。 │ │ 所以需要一个独立的token。 │ │ │ └─────────────────────────────────────────────────┘ pay_token的典型使用流程 ┌─────────────────────────────────────────────────┐ │ │ │ 玩家点击购买648元钻石礼包 │ │ │ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │ │ 游戏 │ │ SDK │ │ 支付 │ │ │ │ 客户端 │ │ 服务器 │ │ 渠道 │ │ │ └───┬────┘ └───┬────┘ └───┬────┘ │ │ │ │ │ │ │ │ ① 创建订单 │ │ │ │ │ 商品:钻石礼包│ │ │ │ │ access_token │ │ │ │ │ ────────────→ │ │ │ │ │ │ │ │ │ │ 验证身份 ✅ │ │ │ │ 创建订单 │ │ │ │ 生成pay_token │ │ │ │ │ │ │ │ │ ② 返回支付信息│ │ │ │ │ order_id │ │ │ │ │ pay_token │ │ │ │ │ ←──────────── │ │ │ │ │ │ │ │ │ │ ③ 调起支付 │ │ │ │ │ pay_token │ │ │ │ │ ─────────────────────────→ │ │ │ │ │ │ │ │ │ │ 用户输入密码 │ │ │ │ 确认支付 │ │ │ │ │ │ │ │ ④ 支付结果 │ │ │ │ │ ←───────────────────────── │ │ │ │ │ │ │ │ │ ⑤ 通知服务器 │ │ │ │ │ order_id │ │ │ │ │ 支付凭证 │ │ │ │ │ ────────────→ │ │ │ │ │ │ │ │ │ │ 验证支付凭证 │ │ │ │ 确认到账 │ │ │ │ 发放钻石 │ │ │ │ │ │ │ │ │ ⑥ 发货完成 │ │ │ │ │ 钻石6480 │ │ │ │ │ ←──────────── │ │ │ │ │ │ │ │ │ │ │ 注意pay_token只在③这一步使用。 │ │ 它的生命周期很短——只为这一笔交易而生。 │ │ 交易完成后这个pay_token就作废了。 │ │ │ └─────────────────────────────────────────────────┘ pay_token的关键特性 ┌─────────────────────────────────────────────────┐ │ │ │ 1. 一次性使用 │ │ │ │ 一个pay_token对应一笔订单。 │ │ 用过就作废。不能重复使用。 │ │ │ │ 防止重放攻击 │ │ 黑客截获了一个pay_token │ │ 试图用它再买一次——服务器拒绝。 │ │ 这个pay_token已经用过了。 │ │ │ │ │ │ 2. 绑定订单 │ │ │ │ pay_token和特定的订单绑定。 │ │ 不能用买648礼包的pay_token去买月卡。 │ │ 商品、金额、用户ID都锁死在token里。 │ │ │ │ │ │ 3. 极短有效期 │ │ │ │ 通常只有几分钟到十几分钟。 │ │ │ │ 用户点了购买5分钟内没完成支付 │ │ pay_token过期。订单关闭。 │ │ 想买重新下单重新生成pay_token。 │ │ │ │ 为什么这么短 │ │ - 防止价格变动限时折扣过期了还能用旧价买 │ │ - 防止库存问题限量商品被长时间占着 │ │ - 减少被截获后的利用窗口 │ │ │ │ │ │ 4. 不可续期 │ │ │ │ access_token过期了可以用refresh_token续。 │ │ pay_token过期了没有refresh_pay_token。 │ │ 重新走一遍下单流程。 │ │ 因为每笔交易都应该是一次独立的、完整的意愿确认。 │ │ │ └─────────────────────────────────────────────────┘ 代码示例——完整的支付流程 ┌─────────────────────────────────────────────────┐ │ │ │ // 玩家点击购买按钮 │ │ void OnBuyButtonClicked(string productId) │ │ { │ │ // 第1步向SDK服务器创建订单 │ │ var request new CreateOrderRequest(); │ │ request.product_id productId; │ │ request.access_token m_AccessToken; │ │ │ │ SDK.CreateOrder(request, OnOrderCreated); │ │ } │ │ │ │ │ │ // 第2步订单创建成功拿到pay_token │ │ void OnOrderCreated(OrderResponse response) │ │ { │ │ string orderId response.order_id; │ │ string payToken response.pay_token; │ │ │ │ // 第3步用pay_token调起支付渠道 │ │ SDK.Pay(orderId, payToken, OnPayResult); │ │ │ │ // 这一步会弹出支付界面 │ │ // 微信支付 → 弹出微信确认页 │ │ // Apple IAP → 弹出App Store确认框 │ │ // 支付宝 → 跳转支付宝App │ │ } │ │ │ │ │ │ // 第4步支付结果回调 │ │ void OnPayResult(PayResult result) │ │ { │ │ switch (result.code) │ │ { │ │ case PayCode.Success: │ │ // 支付成功 │ │ // 但先别急着发货—— │ │ // 等服务器确认到账后再发 │ │ ShowMessage(支付成功正在发货...); │ │ QueryOrderStatus(result.order_id); │ │ break; │ │ │ │ case PayCode.Cancelled: │ │ // 用户取消了支付 │ │ ShowMessage(已取消); │ │ break; │ │ │ │ case PayCode.Failed: │ │ // 支付失败 │ │ ShowMessage(支付失败请重试); │ │ break; │ │ │ │ case PayCode.TokenExpired: │ │ // pay_token过期了 │ │ ShowMessage(订单已超时请重新购买);│ │ break; │ │ } │ │ } │ │ │ └─────────────────────────────────────────────────┘第四章三兄弟的完整对比┌──────────────────────────────────────────────────────────┐ │ │ │ access_token vs refresh_token vs pay_token │ │ │ │ ┌────────────┬──────────────┬──────────────┬──────────┐ │ │ │ │ access_token │refresh_token │pay_token │ │ │ ├────────────┼──────────────┼──────────────┼──────────┤ │ │ │ 酒店类比 │ 房卡 │ 续住凭证 │ 消费手环 │ │ │ ├────────────┼──────────────┼──────────────┼──────────┤ │ │ │ 用途 │ 访问API │ 换新access │ 发起支付 │ │ │ │ │ 证明身份 │ _token │ 完成交易 │ │ │ ├────────────┼──────────────┼──────────────┼──────────┤ │ │ │ 有效期 │ 短 │ 长 │ 极短 │ │ │ │ │ 2小时~24小时 │ 30天~90天 │ 5~15分钟 │ │ │ ├────────────┼──────────────┼──────────────┼──────────┤ │ │ │ 使用频率 │ 极高 │ 低 │ 极低 │ │ │ │ │ 每次请求 │ 续期时 │ 支付时 │ │ │ ├────────────┼──────────────┼──────────────┼──────────┤ │ │ │ 可重复使用 │ ✅ 多次 │ ❌ 通常一次 │ ❌ 一次 │ │ │ ├────────────┼──────────────┼──────────────┼──────────┤ │ │ │ 可续期 │ ❌ │ ❌ │ ❌ │ │ │ │ │ 用refresh换 │ 过期重新登录 │ 重新下单 │ │ │ ├────────────┼──────────────┼──────────────┼──────────┤ │ │ │ 被盗后果 │ 中等 │ 严重 │ 严重 │ │ │ │ │ 能看数据 │ 能持续获取 │ 能花钱 │ │ │ │ │ 不能花钱 │ 新token │ (一笔) │ │ │ ├────────────┼──────────────┼──────────────┼──────────┤ │ │ │ 获取方式 │ 登录/续期 │ 登录 │ 创建订单 │ │ │ ├────────────┼──────────────┼──────────────┼──────────┤ │ │ │ 绑定对象 │ 用户 │ 用户 │ 订单 │ │ │ └────────────┴──────────────┴──────────────┴──────────┘ │ │ │ └──────────────────────────────────────────────────────────┘第五章完整的生命周期——从登录到支付让我们用一个完整的时间线 看看三个token在一个玩家的游戏过程中是怎么协作的。 ┌─────────────────────────────────────────────────────────┐ │ │ │ ⏰ 上午10:00 —— 玩家打开游戏 │ │ │ │ 玩家输入账号密码点击登录。 │ │ │ │ 服务器返回 │ │ ├─ access_token AT_001 过期时间12:00 │ │ ├─ refresh_token RT_001 过期时间30天后 │ │ └─ pay_token 无还没有支付需求 │ │ │ │ 状态 │ │ ┌──────────────────────────────────────────┐ │ │ │ access_token [AT_001] ████████░░ 2小时 │ │ │ │ refresh_token [RT_001] █████████░ 30天 │ │ │ │ pay_token [无] │ │ │ └──────────────────────────────────────────┘ │ │ │ │ │ │ ⏰ 上午10:05 —— 玩家查看角色信息 │ │ │ │ 请求GET /user/profile │ │ 携带access_token AT_001 │ │ 结果✅ 成功返回角色信息 │ │ │ │ │ │ ⏰ 上午10:30 —— 玩家查看排行榜 │ │ │ │ 请求GET /leaderboard │ │ 携带access_token AT_001 │ │ 结果✅ 成功返回排行榜数据 │ │ │ │ │ │ ⏰ 上午11:55 —— access_token快过期了 │ │ │ │ 客户端检测到距离过期还有5分钟。 │ │ 自动发起续期。 │ │ │ │ 请求POST /auth/refresh │ │ 携带refresh_token RT_001 │ │ 结果✅ 成功 │ │ ├─ 新access_token AT_002 过期时间14:00 │ │ └─ 新refresh_token RT_002 旧的RT_001作废 │ │ │ │ 玩家全程无感知。正在打副本。 │ │ │ │ 状态 │ │ ┌──────────────────────────────────────────┐ │ │ │ access_token [AT_002] ████████░░ 2小时 │ │ │ │ refresh_token [RT_002] █████████░ 30天 │ │ │ │ pay_token [无] │ │ │ └──────────────────────────────────────────┘ │ │ │ │ │ │ ⏰ 下午12:30 —— 玩家想买钻石 │ │ │ │ 玩家点击购买648钻石礼包。 │ │ │ │ 第1步创建订单 │ │ 请求POST /order/create │ │ 携带access_token AT_002 │ │ 参数product_id diamond_648 │ │ 结果✅ 成功 │ │ ├─ order_id ORD_20240101_001 │ │ └─ pay_token PT_001 过期时间12:4515分钟 │ │ │ │ 状态 │ │ ┌──────────────────────────────────────────┐ │ │ │ access_token [AT_002] ██████░░░░ 1.5h │ │ │ │ refresh_token [RT_002] █████████░ 30天 │ │ │ │ pay_token [PT_001] ██░░░░░░░░ 15分钟 │ │ │ └──────────────────────────────────────────┘ │ │ │ │ 第2步调起支付 │ │ SDK用pay_token调起微信支付。 │ │ 玩家在微信中确认支付。 │ │ 微信返回支付成功。 │ │ │ │ 第3步服务器确认 │ │ SDK服务器收到微信的支付回调。 │ │ 验证pay_token和订单匹配。 │ │ 确认到账。发放6480钻石。 │ │ pay_token PT_001 作废。 │ │ │ │ 状态 │ │ ┌──────────────────────────────────────────┐ │ │ │ access_token [AT_002] ██████░░░░ 1.5h │ │ │ │ refresh_token [RT_002] █████████░ 30天 │ │ │ │ pay_token [已作废] │ │ │ └──────────────────────────────────────────┘ │ │ │ │ │ │ ⏰ 下午13:55 —— access_token又快过期了 │ │ │ │ 自动续期。用RT_002换新token。 │ │ 拿到AT_003和RT_003。 │ │ 玩家继续打Boss毫无感知。 │ │ │ │ │ │ ⏰ 下午18:00 —— 玩家关闭游戏 │ │ │ │ 三个token保存在本地加密存储中。 │ │ 明天打开游戏时 │ │ - access_token可能已过期 → 用refresh_token续期 │ │ - refresh_token还有29天有效期 → 不需要重新登录 │ │ - pay_token不需要保存 → 下次购买时重新生成 │ │ │ │ │ │ ⏰ 31天后 —— 玩家很久没玩了 │ │ │ │ 打开游戏。 │ │ access_token过期了。 │ │ refresh_token也过期了超过30天。 │ │ 弹出登录界面。 │ │ 玩家重新输入账号密码。 │ │ 获得全新的三个token。 │ │ 一切重新开始。 │ │ │ └─────────────────────────────────────────────────────────┘第六章常见错误和最佳实践┌─────────────────────────────────────────────────┐ │ │ │ ❌ 错误1把token硬编码在URL里 │ │ │ │ // 千万不要这样做 │ │ GET /api/profile?access_tokenabc123 │ │ │ │ URL会被记录在 │ │ - 浏览器历史记录 │ │ - 服务器访问日志 │ │ - 代理服务器日志 │ │ - CDN日志 │ │ │ │ 你的token在十个地方留下了副本。 │ │ 任何一个地方泄露token就暴露了。 │ │ │ │ ✅ 正确做法放在HTTP Header里 │ │ Authorization: Bearer abc123 │ │ Header不会被记录在URL日志中。 │ │ │ │ │ │ ❌ 错误2在客户端判断token是否有效 │ │ │ │ // 不要这样做 │ │ if (jwt.decode(token).exp now) │ │ // token没过期直接用 │ │ │ │ 客户端的时钟可能不准。 │ │ 用户可能故意把手机时间调早。 │ │ 客户端的判断不可信。 │ │ │ │ ✅ 正确做法 │ │ 客户端可以用本地时间做预判提前续期 │ │ 但最终的有效性判断必须由服务器完成。 │ │ 服务器返回401才说明真的过期了。 │ │ │ │ │ │ ❌ 错误3access_token过期后直接弹登录框 │ │ │ │ void OnRequestFailed(int code) │ │ { │ │ if (code 401) │ │ ShowLoginScreen(); // 太粗暴了 │ │ } │ │ │ │ ✅ 正确做法先尝试用refresh_token续期 │ │ │ │ void OnRequestFailed(int code) │ │ { │ │ if (code 401) │ │ { │ │ RefreshToken( │ │ onSuccess: () RetryRequest(), │ │ onFail: () ShowLoginScreen() │ │ ); │ │ } │ │ } │ │ │ │ 只有refresh_token也失败了才让用户重新登录。 │ │ │ │ │ │ ❌ 错误4支付成功后立即发货 │ │ │ │ void OnPayResult(PayResult result) │ │ { │ │ if (result.code Success) │ │ GivePlayerDiamonds(6480); // 危险 │ │ } │ │ │ │ 客户端的支付回调可以被伪造。 │ │ 黑客可以直接调用GivePlayerDiamonds。 │ │ │ │ ✅ 正确做法等服务器确认 │ │ │ │ void OnPayResult(PayResult result) │ │ { │ │ if (result.code Success) │ │ { │ │ // 不在客户端发货 │ │ // 向自己的服务器查询订单状态 │ │ QueryOrderStatus(result.order_id, │ │ (order) { │ │ if (order.status delivered) │ │ // 服务器已经发货了 │ │ RefreshPlayerData(); │ │ }); │ │ } │ │ } │ │ │ │ 发货逻辑永远在服务器端执行。 │ │ 客户端只负责刷新显示。 │ │ │ │ │ │ ❌ 错误5把三个token存在同一个地方 │ │ │ │ // 全部存在PlayerPrefs里 │ │ PlayerPrefs.SetString(access_token, at); │ │ PlayerPrefs.SetString(refresh_token, rt); │ │ PlayerPrefs.SetString(pay_token, pt); │ │ │ │ PlayerPrefs是明文存储。 │ │ 任何人都能打开文件看到你的token。 │ │ │ │ ✅ 正确做法分级存储 │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ │ │ │ │ access_token │ │ │ │ 内存中即可。App关闭就丢失。 │ │ │ │ 下次打开用refresh_token重新获取。 │ │ │ │ │ │ │ │ refresh_token │ │ │ │ 加密存储。 │ │ │ │ iOS: Keychain │ │ │ │ Android: EncryptedSharedPreferences │ │ │ │ PC: 加密文件 DPAPI │ │ │ │ │ │ │ │ pay_token │ │ │ │ 不需要持久化存储。 │ │ │ │ 只在支付流程中临时持有。 │ │ │ │ 支付完成或取消后立即丢弃。 │ │ │ │ │ │ │ └──────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────┘尾声三把钥匙各司其职让我们回到酒店的类比做一个最终的总结。 ┌─────────────────────────────────────────────────┐ │ │ │ 你住在一家高级酒店。 │ │ │ │ 房卡access_token │ │ │ │ 你每天用它几十次。 │ │ 进房间、去健身房、去游泳池。 │ │ 它证明你是这家酒店的住客。 │ │ 24小时后过期。 │ │ 过期了不用回前台出示身份证 │ │ 用续住凭证就能换一张新的。 │ │ │ │ │ │ 续住凭证refresh_token │ │ │ │ 你很少用它。只在房卡过期时用一次。 │ │ 它证明你有权继续住在这里。 │ │ 30天有效。 │ │ 用一次就换一张新的。 │ │ 它比房卡珍贵——丢了房卡用凭证补办就行。 │ │ 丢了凭证你得重新办理入住重新登录。 │ │ 所以你把它锁在保险箱里。 │ │ │ │ │ │ 消费手环pay_token │ │ │ │ 你想在酒店餐厅吃饭时前台给你一个手环。 │ │ 这个手环只能在这一顿饭中使用。 │ │ 吃完饭手环就作废了。 │ │ 下次吃饭再领一个新的。 │ │ 它证明你授权了这笔消费。 │ │ 不能用昨天的手环吃今天的饭。 │ │ 不能用餐厅的手环去SPA消费。 │ │ 一个手环一笔交易用完即弃。 │ │ │ │ │ │ 三把钥匙的设计哲学 │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ │ │ │ │ access_token │ │ │ │ 高频使用 → 短有效期 → 降低泄露风险 │ │ │ │ │ │ │ │ refresh_token │ │ │ │ 低频使用 → 长有效期 → 提升用户体验 │ │ │ │ │ │ │ │ pay_token │ │ │ │ 一次性使用 → 极短有效期 → 保护资金安全 │ │ │ │ │ │ │ │ 三者互相配合 │ │ │ │ access_token负责日常通行 │ │ │ │ refresh_token负责无缝续期 │ │ │ │ pay_token负责安全支付 │ │ │ │ │ │ │ │ 各司其职。缺一不可。 │ │ │ │ │ │ │ └──────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────┘安全系统的最高境界不是一把万能钥匙。而是三把专用钥匙各管各的门。一把丢了另外两把的门依然安全。这就是Token三兄弟存在的意义。