前言
在应用中,我们必须对 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
- 还可以实现其他针对接口的通用功能
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于