CC防护的本质不是挡住所有请求,而是在恶意流量中精准识别并放过正常用户。Token刷新机制与前端重试策略的配合,恰恰是这个识别链条里最容易被设计失误的一环。多数团队把Token当成一个简单的“令牌”,过期就重刷,请求失败就无脑重试,结果正常用户被误伤,攻击者反而利用重试逻辑放大流量。真正有效的方案,需要让Token刷新和重试策略形成闭环,把客户端行为变成防护系统可感知、可验证的信号。
Token在CC防护中的真实角色Token不是用来“鉴权”的,是用来“验身”的。在CC攻击场景下,攻击者通常能伪造HTTP请求的全部字段,唯独难以在客户端执行完整的JavaScript逻辑。Token刷新机制的核心价值就在这里:它要求客户端完成一段计算任务,可能是环境指纹采集、工作量证明挑战、或者浏览器行为验证,然后才能拿到新的有效Token。这个Token一旦绑定到当前会话和客户端环境,就能让后续请求携带一个难以伪造的信任凭证。
很多实现把Token过期时间设得很短,比如60秒,以为这样能增加攻击成本。但实际上,过短的过期时间会导致正常用户在浏览过程中频繁触发刷新,一旦刷新链路出现任何抖动,用户就会看到功能异常。更合理的做法是把Token分为两层:一个短期Token用于请求验证,过期时间可以短;另一个中期Token用于无感刷新短期Token,过期时间可以长一些,并且绑定客户端指纹。这样攻击者即使抓到一个短期Token,也无法独立完成刷新,因为刷新接口会校验中期Token和客户端环境的绑定关系。
Token刷新的三种触发模式第一种是预刷新,也就是在Token即将过期前主动去换新Token。前端可以维护一个定时器,在Token剩余有效时间低于阈值时发起刷新请求。这个阈值需要仔细计算,要考虑网络往返时间、服务端处理时间、以及时钟偏移。通常建议设在有效期的三分之一处,比如Token有效期60秒,就在剩余20秒时触发刷新。预刷新的好处是用户操作不会被中断,但缺点是在用户长时间不活跃时会产生无意义的刷新请求,反而增加服务器负担。
第二种是按需刷新,只有当前Token过期且用户发起新请求时才去刷新。这种模式省资源,但用户体验差,因为每次Token过期后的第一个请求必然会失败,需要前端拦截错误、刷新Token、再重放原始请求。如果这个链路耗时超过500毫秒,用户就能明显感知到卡顿。按需刷新更适合对实时性要求不高的后台管理类应用,不适合面向C端的高频交互场景。
第三种是混合模式,结合前两种的优势。前端在Token有效期内正常使用预刷新逻辑,但如果预刷新失败或者用户从后台切回前台时Token已过期,就降级到按需刷新。这种模式需要前端维护一个状态机,明确记录Token当前处于“有效”、“刷新中”、“已过期”三种状态,避免并发请求同时触发多次刷新。实际落地时,可以用一个Promise锁来实现:多个请求同时发现Token过期时,只有第一个请求真正发起刷新,其他请求共享这个刷新结果。
前端重试策略的核心矛盾重试策略面临一个根本矛盾:重试太快会加剧服务端压力,在攻击场景下等于帮攻击者放大流量;重试太慢则正常用户体验受损。解决这个矛盾的思路,是让重试行为本身携带“我是正常用户”的信号。具体来说,正常用户的重试行为有规律可循:第一次重试通常是立即的,因为可能是网络抖动;第二次重试会有明显延迟,因为用户在等待;第三次及以后的重试间隔会更长,且往往伴随用户的其他交互行为。
攻击脚本的重试行为则完全不同:间隔固定、频率均匀、没有人类操作的随机性。基于这个差异,可以把重试策略设计成阶梯式退避,但退避算法不能是简单的指数增长。指数退避的问题是,当大量正常用户同时遇到网络故障时,他们会在退避窗口结束后几乎同时发起重试,形成“惊群效应”。更好的做法是在指数退避的基础上加入随机抖动,让重试时间点分散开。同时,前端可以在重试请求的Header中带上重试次数和上次失败原因,让服务端能识别这是第几次重试,从而做出差异化处理。
Token刷新与重试的联动设计这是整个方案中最关键的部分。当一次请求因为Token过期而失败时,前端不应该立即重试原始请求,而是应该先完成Token刷新,再带着新Token重放请求。这个流程看似简单,但细节处理不当会引发连锁问题。
首先,Token刷新请求本身也可能失败。如果刷新接口返回429限流或者503,说明服务端正在承受压力,此时前端必须停止一切重试行为,进入静默等待状态。等待时间由服务端在响应头中通过Retry-After字段告知,前端必须严格遵守。很多团队忽略了Retry-After的处理,前端按自己的逻辑继续重试,等于在攻击服务端。
其次,Token刷新期间积压的用户请求需要妥善管理。假设用户在Token过期瞬间连续点击了三个按钮,产生了三个并发请求,这三个请求都会因为Token过期而失败。前端需要用一个队列暂存这些失败的请求,等Token刷新成功后按顺序重放。重放时还要检查每个请求的业务幂等性,避免重复创建订单或重复扣款。对于非幂等的请求,应该在重放前弹出确认提示,或者直接丢弃并提示用户重新操作。
最容易被忽视的是Token刷新与重试的时序安全问题。攻击者可以故意使用过期Token发起请求,观察服务端返回的错误码和刷新接口的行为,从而探测Token的生成规律。防御方法是在刷新接口中加入挑战机制:当同一客户端在短时间内多次刷新Token时,服务端不应直接返回新Token,而是返回一个挑战,要求客户端完成工作量证明或者环境指纹二次验证。前端需要能识别这种挑战响应,自动执行挑战逻辑,拿到新Token后再继续重试队列。
具体的前端实现架构前端需要一个统一的请求拦截器来承载这些逻辑,而不是在每个业务请求里散落Token处理和重试代码。这个拦截器可以分为三层:最底层是Token管理器,负责Token的存储、过期判断、刷新调度;中间层是重试管理器,负责失败请求的队列管理、退避计算、重放执行;最上层是挑战执行器,负责处理服务端下发的验证挑战。
Token管理器需要处理并发刷新问题,核心代码如下:
class TokenManager {
private refreshPromise: Promise | null = null;
async getValidToken(): Promise {
if (this.isTokenValid()) {
return this.currentToken;
}
// 防止并发刷新
if (!this.refreshPromise) {
this.refreshPromise = this.refreshToken()
.finally(() => { this.refreshPromise = null; });
}
return this.refreshPromise;
}
private async refreshToken(): Promise {
const response = await fetch('/api/token/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refresh_token: this.refreshToken,
client_fingerprint: await this.getFingerprint()
})
});
if (response.status === 429 || response.status === 503) {
const retryAfter = response.headers.get('Retry-After');
throw new RateLimitError(parseInt(retryAfter || '5'));
}
const data = await response.json();
this.currentToken = data.access_token;
this.refreshToken = data.refresh_token;
this.expiresAt = Date.now() + data.expires_in * 1000;
return this.currentToken;
}
}
重试管理器需要区分Token过期导致的失败和其他类型的失败。对于Token过期,重试策略是“刷新Token后立即重放”,不需要退避延迟。对于网络错误或5xx错误,才应用阶梯式退避。这个区分逻辑必须准确,否则会把Token过期误判为服务端故障,导致用户等待不必要的延迟。
服务端需要配合的关键点这套机制能生效的前提,是服务端返回的错误信息足够精确。当请求因Token过期被拒绝时,服务端应该返回401状态码,并在响应体中携带一个特定的错误码,比如TOKEN_EXPIRED,而不是笼统的UNAUTHORIZED。前端拦截器只有明确知道失败原因是Token过期,才能执行刷新后重试的逻辑。如果服务端返回的是通用401,前端无法区分是Token过期还是权限不足,就会产生误判。
服务端还需要在Token刷新接口上做频率限制,但这个限制不能是无差别的。应该基于客户端指纹和中期Token来限流,而不是基于IP。因为正常用户在Token即将过期时会产生一次刷新请求,这是预期行为,不应该被限流拦截。限流阈值应该针对异常模式:同一客户端在短时间内多次刷新、或者使用不同中期Token但相同指纹频繁刷新,这些才是攻击特征。
另外,服务端在返回429限流响应时,必须在Retry-After头中给出一个合理的时间。这个时间不是随便填的,应该根据当前服务端负载动态计算。如果服务端已经接近过载,Retry-After可以设到30秒甚至更长;如果只是针对单个客户端的限流,5秒就足够。前端必须严格解析并遵守这个值,任何忽略Retry-After的重试行为都应该被视为恶意行为,服务端可以据此将该客户端标记为高风险。
边界场景的处理用户从后台切回前台是一个高频的边界场景。移动端浏览器在后台时会冻结JavaScript执行,导致Token预刷新的定时器失效。用户切回前台时,Token可能已经过期,此时如果用户立即操作,就会触发按需刷新流程。前端需要监听visibilitychange事件,在页面变为可见时立即检查Token有效性,如果已过期就提前刷新,避免用户操作时产生等待。
另一个容易被忽视的场景是刷新Token本身也过期的情况。中期Token虽然有效期较长,但终究会过期。当中期Token过期时,刷新接口会返回一个特定错误,前端应该清除所有本地状态,重定向到初始验证页面,让用户重新完成一次完整的环境验证。这个流程不能静默执行,因为涉及用户状态的清理,必须有明确的UI反馈。
网络切换场景也值得关注。用户从WiFi切换到移动网络时,IP地址会变化,如果Token绑定了IP,就会导致Token突然失效。更稳健的做法是Token绑定客户端指纹而非IP,指纹可以综合Canvas指纹、WebGL指纹、字体列表等多个维度生成,即使网络环境变化也不受影响。如果业务安全要求必须绑定IP,那至少在IP变化时给用户一个平滑的过渡期,而不是立即让Token失效。
这套Token刷新与前端重试的配合方案,本质上是通过客户端的行为一致性来建立信任。正常用户的行为模式是可预测的、有节制的,而攻击脚本的行为模式是机械的、贪婪的。当Token刷新和重试策略被设计成一个有机整体时,防护系统就能从请求的时序特征、重试的退避规律、挑战的完成质量等多个维度持续评估客户端的可信度,从而在不牺牲用户体验的前提下,有效过滤CC攻击流量。
