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

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

前言

在应用中,我们必须对 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 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3187 引用 • 8213 回帖
  • 框架
    47 引用 • 348 回帖 • 1 关注
  • Spring

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

    944 引用 • 1459 回帖 • 17 关注

相关帖子

欢迎来到这里!

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

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