第4章 过期自动续期测试

4.1 双模型原理

自动续期的核心是双模型:一个短期有效的访问令牌(Access )用于资源请求,一个长期有效的刷新令牌(Refresh )用于获取新的访问令牌。当访问令牌过期时,客户端使用刷新令牌向认证服务请求新令牌,再重试原请求。

设计要点:
  • Access 有效期短(如15分钟),减少泄露风险
  • Refresh 有效期长(如7天),且可以携带旋转(Rotate)机制
  • Refresh 需要安全存储(HttpOnly Cookie / 安全存储库)
  • 服务端必须提供刷新端点(如 /auth/refresh)
属性Access Refresh
有效期短(分钟级)长(天/周级)
用途携带在请求头验证身份仅用于获取新Access
泄露影响有限时间内可访问资源可无限刷新(需配合撤销策略)
存储位置内存或短期缓存安全存储(HttpOnly / 安全存储)
自动续期交互不参与主动发起刷新请求

4.2 自动续期流程步骤

  1. 初始认证:客户端提交凭证,服务端返回Access 和Refresh 。
  2. 正常请求:携Access 请求资源,服务端返回200。
  3. 过期检测:客户端收到401或预先解析JWT的 exp 字段判断剩余时间。
  4. 发起刷新:调用刷新端点,携带Refresh (及可能的CSRF )。
  5. 服务端验证:校验Refresh 有效性、是否被撤销,颁发新Access (可选旋转Refresh )。
  6. 重试原请求:使用新Access 重试失败队列中的请求。
  7. 处理刷新失败:若Refresh 也过期或无效,则强制用户重新登录。
过期检测策略:推荐在每次请求前检查 exp 与当前时间差值(提前5秒预防网络延迟)。若已过期或即将过期则立即刷新,避免发送无效请求。对于无JWT的服务,利用HTTP 401状态码作为触发信号。
双自动续期交互流程 客户端 认证服务 资源服务 过期检测逻辑 自动刷新模块 重试请求队列 刷新失败 → 重新登录 (Refresh 过期/撤销) ① 登录 / 刷新 ② 携带Access 请求 ③ 401 / exp 提前判断 ④ 发起刷新请求(Refresh ) ⑤ 返回新Access ⑥ 重试原请求(新) 刷新失败

4.3 代码实现:自动刷新拦截器

以下Python示例使用 requests.Session 实现自动刷新和请求重试,附注释说明过期检测点。

import time
import requests
from threading import Lock

class AutoRefreshSession:
    def __init__(self, auth_url: str, refresh_url: str, credentials: dict):
        self.session = requests.Session()
        self.auth_url = auth_url
        self.refresh_url = refresh_url
        self.credentials = credentials
        self.access_ = None
        self.refresh_ = None
        self.expires_at = 0   # Unix timestamp
        self._lock = Lock()

    def authenticate(self):
        """首次登录获取双"""
        resp = self.session.post(self.auth_url, json=self.credentials)
        resp.raise_for_status()
        data = resp.json()
        self.access_ = data['access_']
        self.refresh_ = data['refresh_']
        # 假设JWT解析或服务直接返回exp
        self.expires_at = time.time() + data.get('expires_in', 900) - 5

    def _is_expired(self) -> bool:
        return time.time() >= self.expires_at

    def _refresh(self):
        """使用Refresh 获取新Access """
        with self._lock:  # 防止并发刷新
            if not self._is_expired():  # 双重检查
                return
            resp = self.session.post(self.refresh_url, json={
                'refresh_': self.refresh_
            })
            if resp.status_code == 401:
                # Refresh 失效,重新登录
                self.authenticate()
                return
            resp.raise_for_status()
            data = resp.json()
            self.access_ = data['access_']
            # 如果服务端旋转Refresh ,也更新
            if 'refresh_' in data:
                self.refresh_ = data['refresh_']
            self.expires_at = time.time() + data.get('expires_in', 900) - 5

    def request(self, method, url, **kwargs):
        # 每次请求前检查并刷新
        if self._is_expired():
            self._refresh()
        headers = kwargs.get('headers', {})
        headers['Authorization'] = f'Bearer {self.access_}'
        kwargs['headers'] = headers
        resp = self.session.request(method, url, **kwargs)
        # 服务端返回401时也可能触发重试
        if resp.status_code == 401 and not getattr(resp, '_retried', False):
            self._refresh()
            headers['Authorization'] = f'Bearer {self.access_}'
            kwargs['headers'] = headers
            resp = self.session.request(method, url, **kwargs)
            resp._retried = True
        return resp

4.4 测试用例设计

关键测试场景:
  • 正常刷新:先获取,等待过期,触发自动刷新,新可用。
  • 并发刷新:多个请求同时检测过期,确保只刷新一次(锁机制验证)。
  • Refresh 过期:服务端返回401,测试降级到重新登录。
  • 刷新端点异常:网络超时、500错误,验证重试次数和回退策略。
  • 旋转Refresh :验证新旧Refresh 的使用限制。

使用 unittest 编写测试,利用mock模拟服务端响应时间。

import unittest
from unittest.mock import patch, Mock
from freezegun import freeze_time

class TestAutoRefresh(unittest.TestCase):
    def setUp(self):
        self.session = AutoRefreshSession('http://auth/login', 'http://auth/refresh', {'user':'test','pass':'x'})
        # mock首次登录
        with patch.object(self.session.session, 'post') as mock_post:
            mock_post.return_value.status_code = 200
            mock_post.return_value.json.return_value = {
                'access_': 'old_at',
                'refresh_': 'old_rt',
                'expires_in': 900
            }
            self.session.authenticate()

    @freeze_time("2025-01-01 00:00:00")
    def test_auto_refresh_before_expiry(self):
        """提前5秒过期检测,自动触发刷新"""
        with patch.object(self.session.session, 'post') as mock_post:
            # 刷新端点返回新
            mock_post.return_value.status_code = 200
            mock_post.return_value.json.return_value = {
                'access_': 'new_at',
                'refresh_': 'old_rt',  # 不旋转
                'expires_in': 900
            }
            # 模拟时间走到过期前4秒(触发检测)
            with freeze_time("2025-01-01 00:14:56"):
                resp = self.session.request('GET', 'http://resource/api')
                self.assertEqual(resp.request.headers['Authorization'], 'Bearer new_at')

4.5 常见错误与优化

问题原因解决方案
并发请求触发多次刷新缺少锁保护或双重检查使用线程锁 + 双重检查 + 缓存刷新结果
Refresh 被重用导致失效服务端旋转机制未适配刷新后立即更新本地 Refresh
401无效重试死循环登录凭证过期仍继续用旧Refresh 设置最大重试次数,降级到重新登录
过期检测不准确未考虑系统时间偏差预留5秒缓冲 + 使用服务器返回的 exp 字段
刷新端点响应缓慢阻塞请求同步刷新所有请求等待异步刷新(使用Future或异步队列)
存储安全Refresh 明文存储加密存储、HttpOnly Cookie、安全存储(Keychain)

4.6 性能与稳定性优化建议

  • 缓存Access :避免每次请求都解析JWT或查询数据库,使用本地内存缓存。
  • 预测性刷新:在过期前几秒提前异步刷新,减少阻塞。
  • 降级策略:当刷新端点连续失败(如3次),等待指数退避再重试,避免雪崩。
  • 日志监控:记录刷新次数、失败率,告警异常(如频繁刷新、旋转失效)。
  • 无状态设计:服务端Refresh 使用自包含JWT或数据库标记撤销,避免session存储瓶颈。
生产级部署:建议将Refresh 的刷新逻辑封装为中间件(如Python的requests-adapters或awssdk的credential provider),并配合断路器保护认证端点。测试时使用Mock Server模拟各种超时和错误码。