造轮子:开发基于注解的 API 防重,超时,加密插件

本贴最后更新于 2017 天前,其中的信息可能已经时移世易

前言

在应用中,我们必须对 API 接口进行安全保护,例如:身份认证、防注入攻击、防重放攻击、防调包等等。
如果由我们自己手动一个个验证将会非常麻烦,即使写成工具类在每个接口开始前调用,也不免多出一行冗余代码,而如果用拦截器统一拦截又需要在配置文件里写许多拦截配置,一行行写出哪些接口需要拦截。

基于这样的需求,我们可以通过自定义 annotation,来对 API 的每个请求进行自定义校验。

一 设计思路

核心是基于 Spring AOP

创建自定义的注解 -> 注解需要拦截的接口 -> 利用 Spring AOP 对注解做切面 -> 切面提取出 Request 里的参数 -> 对参数做校验(防重放,防超时,签名加密)。

来看具体编码。

二 编码

这里我使用了 Spring Boot 框架(单纯的 Spring 框架也可以)开发。具体代码我放在 github 上,取名叫 aegis

2.1 构建项目

首先引入依赖:

<dependencies> <!--aop相关--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <!--redis相关--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!--web相关--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--内置tomcat--> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-core</artifactId> </dependency> <!--json处理--> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.31</version> </dependency> <!--apache工具库--> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.4</version> </dependency> <!--谷歌工具库--> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>25.1-jre</version> </dependency> <!--lombok插件--> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.16.18</version> </dependency> </dependencies>

然后创建 AegisApiConfig 类用于项目初始化。
在 resources 目录下创建 META-INF/spring.factories 配置文件用于指向当前类,这样其他项目引用该模块时,Spring容器 会自动实例化并加载 AegisApiConfig,再通过 AegisApiConfig 实例化其他类,就实现了整个项目在 Spring容器 里的实例化。加载原理可参考该博客:https://www.jianshu.com/p/5ac61de70ce6

spring.factories:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\com.github.erictao2.aegis.api.AegisApiConfig

同时,Spring Boot 的亮点之一肯定要用到鸭。
AegisApiConfig 实现代码:

@ComponentScan @Configuration @EnableConfigurationProperties(AegisApiProperties.class) public class AegisApiConfig { /** * 运行时的参数Properties对象 */ @Autowired private AegisApiProperties aegisApiProperties; /** * 根据是否有配置redis来确定:使用redis缓存还是使用本地缓存 */ @Bean @ConditionalOnMissingBean(ReplayAttackProcessor.class) public ReplayAttackProcessor replayAttackProcessor(RedisTemplate<String, String> aegisRedisTemplate, CacheSet cacheSet, ApplicationContext context) { boolean useRedis = false; //如果没有加入redis配置的就返回false String property = context.getEnvironment().getProperty("spring.redis.host"); if (StringUtils.isNotBlank(property) && aegisApiProperties.isUseRedis()){ useRedis = true ; } return new CheckReqNoProcessor(aegisRedisTemplate, cacheSet, aegisApiProperties, useRedis); } /** * 判断是否有自定义校验类,如果没有则使用插件默认校验规则,下同 */ @Bean @ConditionalOnMissingBean(ReqTimeoutProcessor.class) public ReqTimeoutProcessor reqTimeoutProcessor() { return new CheckReqTimeoutProcessor(aegisApiProperties); } @Bean @ConditionalOnMissingBean(ModifyParameterProcessor.class) public ModifyParameterProcessor modifyParameterProcessor() { return new CheckModifyParameterProcessor(aegisApiProperties); } @Bean public CacheSet localCache(){ return new CacheSet(aegisApiProperties.getReplayAttacks().getReqNoTimeout()); } }

在该代码中主要有类上的注解:@Configuration 实现自动扫描其他类,@EnableConfigurationProperties(AegisApiProperties.class) 加载配置类。
和类中的方法,决定是否启用 redis 缓存,和校验类的配置。

校验类用于每个拦截的具体校验逻辑,如:如何防重,如何签名加密等。这里用于后续扩展使用,如果其他用户有自定义拦截校验类则会加载自定义的类,如果没有则使用我们的默认类。

然后创建配置类用于加载各种配置参数,也就是刚刚的 AegisApiProperties.class

@ConfigurationProperties(prefix = "aegis.api") @Data public class AegisApiProperties { private boolean useRedis = true; private ReplayAttacksProperties replayAttacks = new ReplayAttacksProperties(); private ReqTimeoutProperties reqTimeout = new ReqTimeoutProperties(); private ModifyParameterProperties modifyParameter = new ModifyParameterProperties(); }

改配置类中又引用了其他子配置类,具体就不一一展示了。
该配置类能够将符合 spring 配置格式的配置内容注入到该对象中,如:
application.properties 中添加

aegis.api.reqTimeout.timeUnit=ms

会根据路径查找到对应的变量:配置文件中的 aegis.api 对应到 AegisApiProperties 类,reqTimeout 对应到 AegisApiProperties 中的 reqTimeout 对象,然后 timeUnit 再对应到 reqTimeout 对象里的 timeUnit 变量。
即:

@Data public class ReqTimeoutProperties { private String timeoutName = "timestamp"; private Long timeout = 30000L; private String timeUnit = "ms"; }

2.2 创建核心注解

接下来创建插件的使用核心————注解。
以实现签名加密的注解为例:

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DefendModify { String name() default ""; String algorithm() default ""; String key() default ""; }

(ps:这里并不过多的讲解注解相关的知识)

定义三个参数,name:签名在 request 里的参数名。algorithm:签名使用的加密算法。key:签名加密中使用的'盐'。
('盐'的含义参考该博客:https://www.cnblogs.com/birdsmaller/p/5377104.html)

2.3 工具类

自定义了一个工具类 RequestUtils,在这里贴一下,方便阅读后续的代码。

public class RequestUtils { /** * 根据JoinPoint获取Request * @param joinPoint * @return */ public static HttpServletRequest getRequest(JoinPoint joinPoint){ ServletRequestAttributes requestAttributes = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes(); HttpServletRequest request = requestAttributes.getRequest(); return request; } /** * 获取Request里对应name的参数,能解析JSON格式的request * @param request * @param name * @return */ public static String getParameter(HttpServletRequest request, String name) { StringBuilder sb = new StringBuilder(); String parameter = ""; parameter = request.getParameter(name); if (StringUtils.isNotBlank(parameter)) { return parameter; } try (BufferedReader br = new BufferedReader(new InputStreamReader( request.getInputStream()))) { String line = null; while ((line = br.readLine()) != null) { sb.append(line); } parameter = URLDecoder.decode(sb.toString(), "UTF-8"); parameter = parameter.substring(parameter.indexOf("{")); JSONObject jsonObject = JSONObject.parseObject(parameter); parameter = jsonObject.getString(name); return parameter; } catch (Exception e) { e.printStackTrace(); } return null; } /** * 获取Request里JSON格式的所有参数 * @param request * @return */ public static String getParameter(HttpServletRequest request) { StringBuilder sb = new StringBuilder(); String parameter = ""; try (BufferedReader br = new BufferedReader(new InputStreamReader( request.getInputStream()))) { String line = null; while ((line = br.readLine()) != null) { sb.append(line); } parameter = URLDecoder.decode(sb.toString(), "UTF-8"); parameter = parameter.substring(parameter.indexOf("{")); return parameter; } catch (Exception e) { e.printStackTrace(); } return null; } }

2.4 切面

使用 @Aspect 来定义了一个切面。
创建切面类 ModifyParameterAspect:

@Data @Aspect @Component public class ModifyParameterAspect { @Autowired protected ModifyParameterProcessor processor; /** * 切面该注解 */ @Pointcut("@annotation(com.github.erictao2.aegis.api.annotation.DefendModify)" + "||@within(com.github.erictao2.aegis.api.annotation.DefendModify)") public void check(){ } @Before("check()&& @annotation(ann)") public void beforeMethod(JoinPoint joinPoint, DefendModify ann) throws Exception { HttpServletRequest request = RequestUtils.getRequest(joinPoint); processor.process(joinPoint, request, ann); } @Before("check()&&@within(ann))") public void beforeClass(JoinPoint joinPoint, DefendModify ann) throws Exception { HttpServletRequest request = RequestUtils.getRequest(joinPoint); processor.process(joinPoint, request, ann); } }

(因为不止一个切面,实际代码中我对切面还做了一层抽象,添加了抽象父类作为模板,这里不影响使用)

这里的代码也不多,创建切面,然后定义切面规则 check(),对于注解使用在类上和方法上分别有不同的切入方法 beforeClassbeforeMethod
(其实可以写成一个方法,但习惯性解耦了留给未来扩展)

切面规则的定义可以参考博客:https://blog.csdn.net/yangshangwei/article/details/77846974

2.5 参数校验

上文可以看到,切面获取到 request 后直接调用了 ModifyParameterProcessorprocess() 方法。
这里是把具体的校验逻辑提取出来放在单独的一系列类里,方便后续扩展与用户自定义校验规则。
对应的类 CheckModifyParameterProcessor:

public class CheckModifyParameterProcessor implements ModifyParameterProcessor { private String signName; private String algorithm; private String key; private boolean isUpperSign; public CheckModifyParameterProcessor(AegisApiProperties aegisApiProperties) { this.signName = aegisApiProperties.getModifyParameter().getSignName(); this.algorithm = aegisApiProperties.getModifyParameter().getAlgorithm(); this.key = aegisApiProperties.getModifyParameter().getKey(); this.isUpperSign = aegisApiProperties.getModifyParameter().isUpperSign(); } @Override public void process(JoinPoint joinPoint, HttpServletRequest request, DefendModify ann) { String signName = StringUtils.isBlank(ann.name()) ? this.signName : ann.name(); String algorithm = StringUtils.isBlank(ann.algorithm()) ? this.algorithm : ann.algorithm(); String key = StringUtils.isBlank(ann.key()) ? this.key : ann.key(); String sign = RequestUtils.getParameter(request, signName); if (StringUtils.isBlank(sign)) { throw new AegisApiException("缺少参数:" + signName); } String json = RequestUtils.getParameter(request); JSONObject jsonObject = JSONObject.parseObject(json); jsonObject.remove(signName); String encodeParameters= digest(jsonObject.toJSONString(), algorithm, key, isUpperSign); if (!StringUtils.equals(sign, encodeParameters)) { throw new AegisApiException("参数签名错误:" + signName + "=" + sign); } } @Override public void setKey(String key){ this.key = key; } private String digest(String input, String algorithm, String key, boolean isUpperSign) { byte[] keys = null; byte[] inputs = null; try { inputs = input.getBytes("UTF-8"); keys = key.getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } algorithm = StringUtils.upperCase(algorithm); String result = null; switch (algorithm) { case"MD5" : result = Hashing.hmacMd5(keys).hashBytes(inputs).toString();break; case"SHA1" : result = Hashing.hmacSha1(keys).hashBytes(inputs).toString();break; case"SHA256" : result = Hashing.hmacSha256(keys).hashBytes(inputs).toString();break; case"SHA512" : result = Hashing.hmacSha512(keys).hashBytes(inputs).toString();break; default:break; } if (isUpperSign) { result = result.toUpperCase(); } return result; } }

这段代码估计没人愿意看(ps:因为我自己添加了很多额外的东西,所以阅读体验不太好)
我大概说说主要干了什么:

  1. 切面调用 process(JoinPoint joinPoint, HttpServletRequest request, DefendModify ann) 方法。
  2. 读取注解上的参数(前文中的 namealgorithmkey 即签名名,算法,盐)
  3. 如果注解上没有配置参数则使用配置类里默认的值(配置类里关于这三个参数都有默认值)
  4. 用工具类读出 Request 里签名值(这个签名值由客户端对参数进行签名加密得出),同时读出 Request 里除了签名的其他所有 Json 参数,根据算法和盐对除了签名的 Json 参数进行签名。
  5. 对比 Request 里签名值是否等于步骤 4 中使用其他参数算出的签名。
  6. 如果验证不通过则抛出异常 AegisApiException
    (ps:如果不了解签名加密可能不太明白,可以参考:https://www.cnblogs.com/codelir/p/5327462.html)

2.6 异常抛出(额外)

其实到这里自定义插件就算完成了,但是拦截请求不通过总得给一个标准的返回。于是我自定义了异常 AegisApiException,然后统一拦截。

添加全局异常拦截 ErrorController

@ControllerAdvice public class ErrorController { @ExceptionHandler(AegisApiException.class) @ResponseBody public ResponseEntity<SimpleResponse> processUnauthenticatedException(NativeWebRequest request, Exception e) { log.error("请求出现异常:", e); SimpleResponse response = new SimpleResponse(); response.setMessage(e.getMessage()); ResponseEntity<SimpleResponse> responseEntity = null; if (e instanceof AegisApiException) { responseEntity = new ResponseEntity<>(response, ((AegisApiException)e).getHttpStatus()); } else { responseEntity = new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); } return responseEntity ; } }

所有拦截到的异常都被包装成自定义返回类 SimpleResponse,然后返回给请求的客户端。(返回类就补贴代码了)

三 插件使用

其实我的项目里具体使用文档:https://github.com/EricTao2/aegis/blob/master/README_CN.md

添加依赖

<dependency> <groupId>com.github.com.erictao2</groupId> <artifactId>aegis-api</artifactId> <version>1.0.1</version> </dependency>

在接口的方法(类)上加上注解:

@RestController //@DefendModify public class DemoController { @PostMapping("/2") //可以自定义参数名,无则使用默认值 //@DefendModify(name ="reqSign", algorithm = "md5", key = "aegis-key") @DefendModify public String post(){ return "post-demo" ; } }

当客户端与服务端的签名不一致时,请求会被拦截返回:

两边签名一致的话,请求才能通过。

四 额外

这个项目仅仅是个人业务之作,所以肯定有部分业务细节考虑不周,请见谅。

并且还有许多可以改进的地方,因为时间原因我就没一一实现了:

  1. 基于配置进行范围拦截,如拦截/security/*形式的 url
  2. 还可以实现其他针对接口的通用功能
  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3192 引用 • 8214 回帖 • 2 关注
  • 框架
    46 引用 • 346 回帖 • 1 关注
  • Spring

    Spring 是一个开源框架,是于 2003 年兴起的一个轻量级的 Java 开发框架,由 Rod Johnson 在其著作《Expert One-On-One J2EE Development and Design》中阐述的部分理念和原型衍生而来。它是为了解决企业应用开发的复杂性而创建的。框架的主要优势之一就是其分层架构,分层架构允许使用者选择使用哪一个组件,同时为 JavaEE 应用程序开发提供集成的框架。

    943 引用 • 1460 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...