必须实现双token静默续期:服务端签发隔离密钥的access_token(15分钟)和refresh_token(7天),前者存body后者设httponly cookie;中间件捕获401并自动刷新,前端axios拦截重试,配合redis黑名单、device_hash校验及预刷新机制保障安全无感。

用户在 ThinkPHP 8.0 项目中调用接口时突然收到 401 错误,页面卡住、表单未提交成功、支付流程中断——这不是要重新登录,而是 access_token 已过期但前端没感知,后端又没提供静默续期能力;必须让 refresh_token 在后台自动换新 token 并重发原请求,整个过程用户完全无感。
服务端生成双 Token 对(access + refresh)
登录成功后,服务端需一次性签发两个 JWT:短时效的 access_token(如 15 分钟)用于日常接口鉴权,长时效的 refresh_token(如 7 天)专用于续期,且二者密钥必须隔离。
使用 firebase/php-jwt 库生成,【务必禁用 alg=none 攻击路径】:JWT::decode() 第三个参数只能传 ['HS256'],不可留空或传入全算法数组。
access_token 的 payload 中必须包含 exp(Unix 时间戳)、iat、jti(随机 16 字节 hex),jti 同步写入 Redis,TTL 设为略长于 access_token 有效期(如 930 秒)。
refresh_token 单独生成 payload,含 exp、user_id、device_hash(SHA256(User-Agent + IP)),用独立密钥 REFRESH_SECRET 签名,不塞权限字段。
最终返回 JSON 中,access_token 放 body,refresh_token 必须设为 HttpOnly; Secure; SameSite=Strict 的 Cookie,禁止 JS 读取。
TP8.0 中间件拦截 401 并触发静默续期
在 app/middleware/TokenRefresh.php 中定义中间件,仅对带 Authorization: Bearer xxx 的请求生效。
捕获响应状态码为 401 且响应头含 X-Token-Expired: true 时,中断当前响应流,改走续期逻辑。
从 Cookie 中安全读取 refresh_token → 校验签名与 exp → 查询 Redis 确认 jti 未被标记为已用 → 验证 device_hash 是否匹配当前请求指纹。
全部通过后,生成一对新 token → 将新 access_token 返回至响应 body → 【用 Set-Cookie 覆盖旧 refresh_token,Max-Age 必须重置】 → 原请求上下文暂存至 TP 的 Request::with() 中供后续复用。
前端 Axios 拦截器配合实现无感重发
方法一:响应拦截器中识别 401 + X-Token-Expired 头 → 阻断 Promise 链 → 触发刷新请求(POST /api/auth/refresh,不带 Authorization,依赖 Cookie 自动携带)。
方法二:刷新成功后,用新 access_token 替换 localStorage 或内存中的旧值 → 将被拦截的原始请求 clone 一份 → 修改其 Authorization header → 发起重试。
注意:重试前必须清空原请求的 FormData 或 JSON body 缓存,否则可能因重复提交导致业务异常(如重复下单)。
所有重试请求需加 X-Retry: true 请求头,后端据此跳过二次续期逻辑,避免嵌套循环。
关键安全加固步骤
第一步:refresh_token 每次使用后,立即将其 jti 写入 Redis 黑名单,TTL = access_token 有效期 + 30 秒,防止重放攻击。
第二步:access_token 过期前 2 分钟内,前端主动发起预刷新(不阻塞 UI),降低高并发下 token 雪崩概率。
第三步:服务端校验 refresh_token 时,强制比对 device_hash,若不匹配则拒绝刷新并清空该用户所有 refresh_token 记录。
第四步:TP8.0 的 JwtAuth 验证器中,access_token 解析失败时,不得返回详细错误信息(如 “Signature has expired”),统一返回 401,避免暴露过期时间。











