在一个 RESTful 服务中,幂等操作意味着客户端可以重复进行相同的调用,而不会改变系统的状态,因此总是获得相同的结果(如果在请求之间没有发生其他事件的话)。
查询操作(通常是 GET
,HEAD
,OPTIONS
,TRACE
)只是获取资源和信息,不会对系统的状态产生影响,可以认为是幂等的。
修改和删除操作(通常是 PUT
,DELETE
)一般被认为是幂等的。需要注意在一些系统中,首次删除资源会返回 HTTP 200
,之后的调用尝试将会返回 HTTP 204(No Content)
或 HTTP 404(Not Found)
。尽管响应不同,系统的状态并没有发生改变。
创建资源(通常是 POST
)修改了系统的状态,因此不是幂等的。
重复调用非幂等接口可能会造成一些预料外的问题,例如重复添加资源产生了垃圾数据,或者启动了过多的任务空耗资源。
一般来说,重复调用可能来自恶意用户的重放攻击或是合法用户的重复提交。
前端防止合法用户的重复提交
一般来说,表单页面的提交按钮在响应事件中会首先禁用自身,或是立即启用一个遮罩层阻断操作。
但有时候经验不足的开发者可能没有意识到这里的交互需要做处理,又有可能是我们俗称的“卡了”导致应对机制还没来得及执行。
可以用防抖(debounce)和节流(throttle):
- 防抖:触发事件后在指定的时间窗口内没有新的事件触发,则执行对应的事件处理器;
- 节流:触发事件后在指定的时间窗口内只执行一次响应;
对于 JavaScript 这样函数特性非常棒的语言,有很多第三方包可以为给定方法添加节流功能,得到供其他组件调用的新函数。
// 这个方法可能会意外触发多次
function doPost(...args) { ... }
// 这个方法 3 秒钟内只会响应一次,可以添加到按钮的点击事件响应方法中
function throttledDoPost = throttle(fn = doPost, windowSizeInSeconds = 3);
后端防止恶意用户的请求重放攻击
在请求中添加时间戳和 Nonce,然后使用 HTTPS
我们可以参考 Twitter API 中的 OAuth 1.0a User Context Authentication methods:
oauth_timestamp: 1318622958
oauth_nonce: kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg
oauth_signature: tnnArxj06cWHq44gCs1OSKk/jLY=
客户端需要在请求中通过 oauth_timestamp
提交当前时间戳,服务端在收到请求时比对系统时钟,超过一定时间的视为非法请求。
现实世界中我们无法保证客户端和服务端的时间一致,又或者请求需要经过层层转发,又或者是其他什么原因导致系统延时不稳定,就需要增大超时时间。如果我们认为 5 秒的延迟是可接受的,那么这 5 秒内的重放都将被视为合法请求。
于是我们引入 nonce,词源上大概是 number used once 的意思,不过给出一个无碰撞性足够好的随机字符串就行。
nonce 一般分为服务器 nonce 和客户端 cnonce,在一个典型的身份验证流程中大概是这么用的:
Twitter API 中的 oauth_nonce
使用的就是 cnonce。如果服务端在处理请求时发现这个 nonce 短期内出现过,就认定这是个重放请求。
如果使用了 HTTPS,那么攻击者只能重放这个包,足够解决问题了。
如果攻击者通过伪造 SSL 证书(或者两个系统使用 HTTP 直接通信)实施了中间人攻击,就需要增加一个签名 oauth_signature
,把 oauth_timestamp
和 oauth_nonce
纳入到计算过程中去。
关于签名的规范各家有自己的规定,也可以参考 Twitter API 的 Creating a signature。
限流算法
限流,即在一定时间区间对请求数量进行限制。主要有几个概念:
- 阈值:单位时间内允许的请求数量,或称 QPS;
- 拒绝策略:拒接请求或挂起;
常见算法与 Java Packages
固定窗口/计数器:单位时间周期内记录请求数量,超过阈值则拒绝接下来的请求。下个时间周期清零计数器;相应的还有滑动窗口计数器。
令牌桶(token-bucket):以一定速率向固定容量的桶中放入令牌,客户端请求需先从桶中取出令牌,如果无令牌可分配则挂起或拒绝请求。可以实现在固定时间区间内最多处理 N 个请求。
可以参考:
注意事项
需要注意的是,如果一位用户熟悉各种调试工具,能够去除前端页面上的种种限制,签发新的 nonce,使用新的时间戳签名,“正常”地重复触发表单提交流程,或是可以使用 Postman 等工具完整的构造请求,以上种种措施就失效了。
如果你还需要维持对其的正常服务,就需要业务层面的去重了。这样的问题需要具体分析,例如找出业务字段(及其组合)中的唯一索引,用来生成 Rate Limiter 的 key,或是加上锁什么的。
如果不需要的话,简单粗暴地禁止同一用户在一个时间段内再次调用某个 endpoint 也是可以的。大部分情况下在网络基础设施层面就可以做到。
实现一个玩具级的 Filter 拦截重放请求
免责声明:如果你需要在一个严肃的系统中实现访问速率控制,应该考虑 taptap/ratelimiter-spring-boot-starter 这种成熟的、自定义项丰富的、支持分布式的产品。
不过这种东西无论是原理还是实现起来其实都并不复杂。因此本文依托 Spring Boot,试图通过实现一个玩具级的 OniFilter 说明如何检查用户请求中的特定请求头,拒绝重复提交,以及如何通过 application.properties
和注解实现一定程度的自定义能力。
实现 Filter
在 doFilterInternal
方法中读取必要的请求头,进行验证。
@Component
public class OniFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws IOException, ServletException {
String nonce = request.getHeader("X-Ca-nonce");
long sentTimestamp = Long.parseLong(
request.getHeader("X-Ca-timestamp"));
// do your filter logic here
// ...
filterChain.doFilter(request, response);
}
}
- 当指定的两个请求头不存在或解析异常,抛出「请求构造异常」异常
- 如果
sentTimestamp
与系统当前时间相差过大,抛出「请求过期」异常 - 把接收到的 Nonce 存放在缓存中,设置一个过期策略,当新请求提交的 Nonce 在缓存中能够命中时,抛出「请求过多」异常
这个时候核心功能就完成了。
处理异常
一般而言我们推荐直接在出现问题的地方抛出异常,然后通过 @RestControllerAdvice
和 ExceptionHandler
声明如何处理异常,向调用方返回特定的数据结构(例如 ProblemDetail)。
不过我们现在写的是 Web Filter,而 Filters 总是在 Servlet 触发前执行,因此指望 DispatcherServlet 中的 @ControllerAdvice
是不太可能的。
如果使用了 Spring Security filter chain,可以定义 FilterChainExceptionHandler
来处理。
我在这里选择注入 HandlerExceptionResolver
,产生异常时不直接抛出,而是传递给其 HandlerExceptionResolver#resolveException
方法,从而利用已有的 ExceptionHandler
。
if (Math.abs(currentTimestamp - sentTimestamp) > MAX_DELAY_IN_SEC) {
handlerExceptionResolver.resolveException(request, response, null,
new RequestExpiredException());
return;
}
组装 ProblemDetail 结构:
@ResponseStatus(HttpStatus.REQUEST_TIMEOUT)
@ExceptionHandler(RequestExpiredException.class)
public ProblemDetail handleExceptions(RequestExpiredException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.REQUEST_TIMEOUT,
"Request Expired");
基于 application.properties
的自定义配置
由于网络环境和应用场景的不同,X-Ca-timestamp
允许的超时时间也应该是不同的,我们希望可以在 application.properties
里自定义。此外如果请求中已经存在时间戳,也可以要求 OniFilter 转而读取这些字段……
最重要的是允许通过 enabled
属性简单地开关这个功能(否则调试接口的开发人员也许会顺着网线来找你)。
# 实际上用的 application.yml
# 没什么区别
oni:
client-nonce-and-timestamp-check:
enabled: true
# 可接受的延时
max-delay-in-second: 3
# 需要读取的请求头
# 根据规范,请求头是大小写不敏感的
client-nonce-header: x-ca-nonce
timestamp-header: x-ca-timestamp
# 哪些 HTTP 方法访问接口时需要接受检查
apply-on-methods:
- POST
- PUT
编写一个属性能够匹配上的 Properties
类和对应的 AutoConfiguration
类:
@ConfigurationProperties(value = "oni.client-nonce-and-timestamp-check")
public class FilterProperties {
private boolean enabled = true;
private long caffeineCacheSize = 10_000;
private long maxDelayInSecond = 5;
private String clientNonceHeader = Constant.DEFAULT_HEADER_NAME_FOR_CLIENT_NONCE;
private String timestampHeader = Constant.DEFAULT_HEADER_NAME_FOR_TIMESTAMP;
private List<String> applyOnMethods = List.of("POST");
@ConditionalOnProperty
说明 oni.client-nonce-and-timestamp-check.enabled=true
时才进行初始化和配置。
@Configuration
@ConditionalOnProperty(prefix = Constant.PROPERTIES_PREFIX,
name = Constant.PROPERTY_ENABLED,
havingValue = "true")
@EnableConfigurationProperties(FilterProperties.class)
public class FilterAutoConfiguration {
private final FilterProperties filterProperties;
public FilterAutoConfiguration(FilterProperties filterProperties) {
this.filterProperties = filterProperties;
}
}
接下来就可以在 OniFilter 中注入 FilterProperties
读取自定义配置了。
例如 oni.client-nonce-and-timestamp-check.apply-on-methods
指定 POST
方法和 PUT
方法需要经过过滤器检查,就可以使用 filterProperties.getApplyOnMethods().contains(requestMethods)
判断。
为了让代码清晰一些,我们把检查逻辑移动到 OniFilter#shouldNotFilter
方法中。
识别当前请求的 HandlerMethod
一般来说 GET
、DELETE
、PUT
方法都是幂等的,POST
方法的接口才需要检查重放。如果我们可以通过注解小范围地改变这个行为就好了。
创建一个 @Oni
注解,这个注解可以指定其修饰的 Controller 方法是否要检查重放参数。
@Target({METHOD})
@Retention(RUNTIME)
public @interface Oni {
boolean enabled() default true;
}
在 OniFilter 中注入 RequestMappingHandlerMapping
,可以获得 HandlerMethod 信息,然后进一步获取注解信息。
if (!ServletRequestPathUtils.hasParsedRequestPath(request)) {
ServletRequestPathUtils.parseAndCache(request);
}
HandlerMethod handlerMethod;
try {
handlerMethod = (HandlerMethod) requestMappingHandlerMapping.getHandler(request).getHandler();
} catch (Exception e) {
// 获取请求 handler 失败,不做下一步验证
return true;
}
Method method = handlerMethod.getMethod();
if (method.isAnnotationPresent(Oni.class)) {
var annotation = method.getAnnotation(Oni.class);
每一次请求都要查询和解析对应的 Controller 方法和注解,还有优化的空间。
这部分逻辑也加入 OniFilter#shouldNotFilter
。
自定义缓存服务
通过 Caffeine 实现了客户端 Nonce 的本地缓存。AutoConfiguration
中构造了 CaffeineNonceCacheService
类,使用 Caffeine#newBuilder
初始化缓存,在 OniFilter 中注入了对应的 Bean。
if (nonceCacheService.isPresent(nonce)) {
handlerExceptionResolver.resolveException(request, response, null,
new TooManyRequestsException("Too many request"));
return;
}
那么问题来了,现在我们的后端服务要部署两个实例,如何替换成一个支持分布式的缓存服务呢?
抽出 NonceCacheService
接口,OniFilter
只需要知道自己会获得一个 NonceCacheService
类型的 Bean 然后调用 NonceCacheService#isPresent
即可。
用户则通过该接口实现自己的缓存服务,例如 RedisNonceCacheService
。
通过 oni.client-nonce-and-timestamp-check.use-custom-cache
指定这个缓存服务类:
oni:
client-nonce-and-timestamp-check:
enabled: true
use-custom-cache: cc.ddrpa.jwtshowcase.RedisNonceCacheService
我们使用条件注解在 AutoConfiguration
类中选择性地构造 NonceCacheService
类型实例:
@Bean
@ConditionalOnProperty(prefix = Constant.PROPERTIES_PREFIX,
name = Constant.PROPERTY_CUSTOM_CACHE_SERVICE_CLASS)
public NonceCacheService customNonceCacheService()
throws ReflectiveOperationException {
return (NonceCacheService)
this.getClass().getClassLoader()
.loadClass(filterProperties.getUseCustomCache())
.getConstructor().newInstance();
}
@Bean
@ConditionalOnMissingBean(NonceCacheService.class)
public NonceCacheService defaultNonceCacheService() {
return new CaffeineNonceCacheService(
filterProperties.getMaxDelayInSecond(),
filterProperties.getCaffeineCacheSize());
}