前言
在应用中,我们必须对 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()
,对于注解使用在类上和方法上分别有不同的切入方法 beforeClass
、beforeMethod
。
(其实可以写成一个方法,但习惯性解耦了留给未来扩展)
切面规则的定义可以参考博客:https://blog.csdn.net/yangshangwei/article/details/77846974
2.5 参数校验
上文可以看到,切面获取到 request 后直接调用了 ModifyParameterProcessor
的 process()
方法。
这里是把具体的校验逻辑提取出来放在单独的一系列类里,方便后续扩展与用户自定义校验规则。
对应的类 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:因为我自己添加了很多额外的东西,所以阅读体验不太好)
我大概说说主要干了什么:
- 切面调用
process(JoinPoint joinPoint, HttpServletRequest request, DefendModify ann)
方法。 - 读取注解上的参数(前文中的
name
、algorithm
、key
即签名名,算法,盐) - 如果注解上没有配置参数则使用配置类里默认的值(配置类里关于这三个参数都有默认值)
- 用工具类读出
Request
里签名值(这个签名值由客户端对参数进行签名加密得出),同时读出Request
里除了签名的其他所有Json
参数,根据算法和盐对除了签名的Json
参数进行签名。 - 对比
Request
里签名值是否等于步骤 4 中使用其他参数算出的签名。 - 如果验证不通过则抛出异常
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" ; } }
当客户端与服务端的签名不一致时,请求会被拦截返回:
两边签名一致的话,请求才能通过。
四 额外
这个项目仅仅是个人业务之作,所以肯定有部分业务细节考虑不周,请见谅。
并且还有许多可以改进的地方,因为时间原因我就没一一实现了:
- 基于配置进行范围拦截,如拦截/security/*形式的 url
- 还可以实现其他针对接口的通用功能
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于