【JavaWeb】如何优雅的实现第三方开放api接口签名(有状态/无状态)

接口签名的必要性

在为第三方系统提供接口的时候,肯定要考虑接口数据的安全问题,比如数据是否被篡改数据是否已经过时数据是否可以重复提交等问题。其中我认为最终要的还是数据是否被篡改。在此分享一下我的关于接口签名的实践方案。

什么是重放攻击

  • 重放攻击,web漏洞中称会话重放漏洞,又称重播攻击、回放攻击
  • 指的是 先截取主机A发送给主机B的报文,入侵这把A请求B的报文原封不动地再发送一次,两次...n次,使主机B误以为入侵者就是主机A,然后进入到正常逻辑中并返回响应。如果是付款接口,或者购买接口就会造成损失,因此需要采用防重放的机制来做请求验证,如请求参数上加上timestamp时间戳+nonce随机数(下面有讲)

HTTPS数据加密是否可以防止重放攻击

不可以,加密可以有效防止明文数据被监听,但是却防止不了重放攻击

开发中的appId、appKey、appSecret到底是什么

  • appID:应用的唯一标识

    用来标识你的开发者账号的, 即:用户id, 可以在数据库添加索引,方便快速查找,同一个 appId 可以对应多个 appKey+appSecret,达到权限的

  • appKey:公匙(相当于账号)

    公开的,调用服务所需要的密钥。是用户的身份认证标识,用于调用平台可用服务.,可以简单理解成是账号。

  • appSecret:私匙(相当于密码)

    签名的密钥,是跟appKey配套使用的,可以简单理解成是密码。

  • token:令牌(过期失效)


使用方法

  1. 向第三方服务器请求授权时,带上AppKey和AppSecret(需存在服务器端)
  2. 第三方服务器验证appKey和appSecret在数据库、缓存中有没有记录
  3. 如果有,生成一串唯一的字符串(token令牌),返回给服务器,服务器再返回给客户端
  4. 后续客户端每次请求都需要带上token令牌

为什么 要有appKey + appSecret 这种成对出现的机制呢,?

  • 因为要加密, 通常用在首次验证(类似登录场景), 用 appKey(标记要申请的权限有哪些) + appSecret(密码, 表示你真的拥有这个权限)来申请一个token, 就是我们经常用到的 accessToken(通常拥有失效时间), 后续的每次请求都需要提供accessToken 表明验证权限通过。

权限划分

现在有了统一的appId,此时如果针对同一个业务要划分不同的权限,比如同一功能,某些场景需要只读权限,某些场景需要读写权限。这样提供一个appId和对应的秘钥appSecret就没办法满足需求。 此时就需要根据权限进行账号分配通常使用appKey和appSecret。

  • 由于 appKey 和 appSecret 是成对出现的账号, 同一个 appId 可以对应多个 appKey+appSecret, 这样平台就为不同的appKey+appSecret对分配不一样的权限,
    • 可以生成两对appKey和appSecret。一个用于删除,一个用于读写,达到权限的细粒度划分。如 : appKey1 + appSecect1 只有删除权限 但是 appKey2+appSecret2 有读写权限… 这样你就可以把对应的权限 放给不同的开发者。其中权限的配置都是直接跟appKey 做关联的, appKey 也需要添加数据库索引, 方便快速查找

简化的场景:

  • 第一种场景:通常用于开放性接口,像地图api,会省去app_id和app_key,此时相当于三者相等,合而为一 appId = appKey = appSecret, 。这种模式下,带上app_id的目的仅仅是统计某一个用户调用接口的次数而已了。
  • 第二种场景: 当每一个用户有且仅有一套权限配置 可以去掉 appKey, , 直接将app_id = app_key, 每个用户分配一个appId+ appSecret就够了`.

也可以

可以采用签名(signature)的方式: 当调用方向服务提供方法发起请求时,带上(appKey、时间戳timeStamp、随机数nonce、签名sign) 签名sign 可以使用 (AppSecret + 时间戳 + 随机数)使用sha1、md5生成,服务提供方收到后,生成本地签名和收到的签名比对,如果一致,校验成功


一.签名流程

接口签名流程

二.签名规则

  1. 分配appId(开发者标识)appSecret(密钥),给 不同的调用方

    可以直接通过平台线上申请,也可以线下直接颁发。appId是全局唯一的,每个appId将对应一个客户,密钥appSecret需要高度保密。

  2. 加入timeStamp(时间戳),以服务端当前时间为准,单位为ms ,5分钟内数据有效

    时间戳的目的就是为了减轻DOS攻击。防止请求被拦截后一直尝试请求接口。服务器端设置时间戳阀值,如果服务器时间 减 请求时间戳超过阀值,表示签名超时,接口调用失败。

  3. 加入临时流水号nonce至少为10位有效期内防重复提交

    随机值nonce 主要是为了增加签名sign的多变性,也可以保护接口的幂等性,相邻的两次请求nonce不允许重复,如果重复则认为是重复提交,接口调用失败。

    • 针对查询接口,流水号只用于日志落地,便于后期日志核查。
    • 针对办理类接口需校验流水号在有效期内的唯一性,以避免重复请求。
通过在接口签名请求参数加上 时间戳timeStamp + 随机数nonce 可以防止 ”重放攻击“
1.时间戳(timeStamp):  
  以服务端当前时间为准,服务端要求客户端发过来的时间戳,必须是最近60秒内(假设值,自己定义)的。
  这样,即使这个请求即使被截取了,也只能在60s内进行重放攻击。
2.随机数(nonce): 
   但是,即使设置了时间戳,攻击者还有60s的攻击时间呢!
   所以我们需要在客户端请求中再加上一个随机数(中间黑客不可能自己修改随机数,因为有参数签名的校验呢),
   服务端会对一分钟内请求的随机数进行检查,如果有两个相同的,基本可以判定为重放攻击。
   因为正常情况下,在短时间内(比如60s)连续生成两个相同nonce的情况几乎为0

服务端“第一次”在接收到这个nonce的时候做下面行为:
  1.去redis中查找是否有key为nonce:{
    
    nonce}的数据
  2.如果没有,则创建这个key,把这个key失效的时间和验证timestamp失效的时间一致,比如是60s。
  3.如果有,说明这个key在60s内已经被使用了,那么这个请求就可以判断为重放请求。
  1. 加入签名字段sign,获取调用方传递的签名信息。
通过在接口签名请求参数加上 时间戳appId + sign 解决身份验证和防止 ”参数篡改“
1.请求携带参数appId和Sign,只有拥有合法的身份appId和正确的签名Sign才能放行。这样就解决了身份验证和参数篡改问题。
2.即使请求参数被劫持,由于获取不到appSecret(仅作本地加密使用,不参与网络传输),也无法伪造合法的请求。

以上字段放在请求头中。

三.签名的生成

1.签名signature字段生成规则

鉴权参数 = 请求头签名参数(appId,timeStamp,nonce) + 请求URL地址(调用方请求接口完整url地址) + 请求Request参数(针对是Get请求时) + 请求Body(非针对是Get请求时 ,如Post请求)
  1. 先将鉴权参数以key-value的格式存储,并以key值正序排序,进行拼接 如: key1value1key2value2

  2. 最后将上面拼接的字符串在拼接应用密钥appSecret,如: key1value1key2value2 + appSecret

  3. 将最终拼接成的字符串转成utf-8的字节数组,后然后做Md5不可逆加密 Md5(key1value1key2value2 + appSecret) 得到的字符串作为签名signature

2.请求参数描述

2.1.请求头

请求头="appId=xxxx&nonce=xxxx×tamp=xxxx&sign=xxx"

请求头中的4个参数是必须要传的,否则直接报异常

2.2.请求URL

请求该接口的完整地址

https://mso.xxxx.com.cn/api/user

2.3.请求数据

请求数据的拼接规则

  • Path:按照path中的顺序将所有value进行拼接

    URL 路径参数指的是通过在 URL 的斜杠后面传递的参数。比如我们要访问 id 为 2 的 project, 则可以访问 /project/2 这个 URL。

  • Query:按照key字典顺序排序,将所有key=value进行拼接

    查询字符串参数(query string)和 路径参数类似,你也可以通过查询字符串的形式传递 id。查询字符串就是在 url 中通过 ? 号后面加参数。比如 /project/?id=2 这种形式。

  • Form:按照key字典顺序排序,将所有key=value进行拼接

    表示为 表单请求时携带的数据,

  • Body

    表示是一个raw数据请求(纯字符串格式),比如json的方式传递。

    1. Json: 按照key字典顺序排序,将所有key=value进行拼接
      (例如{“a”:“a”,“c”:“c”,“b”:{“e”:“e”}} => a=a_b=e=e_c=c)
    2. String: 整个字符串作为一个拼接

分别对应 SpringMvc提供的获取参数的注解

  • @RequestParam: 处理(前端)Content-Type为 application/x-www-form-urlencoded或者form-data编码的内容

  • @PathVariable: 模板变量,一般用于get请求, 即 XXX/{XXXid}

  • @RequestBody:常用来处理Content-Type为application/json, application/xml码的内容,前端规定的是raw方式
    在这里插入图片描述
    如果存在多种数据形式,同种数据内按照上面描述的四种规则进行拼接,拼接好后,不同的数据格式则按照path、query、form、body的顺序进行二次拼接,得到所有数据最终的拼接值。

四.接口签名的实现

1.实现步骤

基本原理其实也比较简单,就是自定义过滤器或者拦截器,对每个请求进行拦截处理,在服务端取到调用方的参数后按同样的签名规则进行匹配

整体流程如下:

  1. 验证请求头参与签名的必传参数
  2. 获取请求头参数,Url请求路径 ,请求数据,把这些值放入SortMap中进行排序
  3. SortMap里面的值进行拼接
  4. 对拼接的值进行加密,生成签名sign
  5. 后台生成的签名sign调用方传入的签名sign进行比较,如果不相同就返回错误

2.实现代码

2.0.需要用到的依赖

  <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- spring2.X集成redis所需common-pool2,使用jedis必须依赖它-->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.72</version>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.11</version>
        </dependency>
        
        <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
            <version>1.14</version>
        </dependency>

2.1.常量类

常量类-声明接口签名需要用的字段名

public class Constant {
    
    
    /**
     * 应用id
     */
    public static final String APP_ID ="appId";
    /**
     * 时间戳,增加链接的有效时间,超过阈值,即失效
     */
    public static final String TIME_STAMP ="timeStamp";
    /**
     *签名
     */
    public static final String SIGN ="sign";
    /**
     * 临时流水号/随机串 ,至少为10位 ,有效期内防重复提交
     */
    public static final String NONCE ="nonce";
    /**
     * 请求url
     */
    public static final String REQ_URL ="reqUrl";
}

2.2.签名过滤器

自定义过滤器-拦截每个请求

@Component
@Slf4j
public class SignAuthFilter extends OncePerRequestFilter {
    
    
    //springBoot获取图标路径
    static final String FAVICON = "/favicon.ico";
    static final String PREFIX = "attack:signature:";

    /**
     * OncePerRequestFilter过滤器保证一次请求只调用一次doFilterInternal方法;如内部的forward不会再多执行一次
     *
     * @param request
     * @param response
     * @param filterChain
     * @throws ServletException
     * @throws IOException
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    
    
        //包装HttpServletRequest对象,缓存body数据,再次读取的时候将缓存的值写出,解决HttpServetRequest读取body只能一次的问题
        HttpServletRequest requestWrapper = null;
        if (request instanceof HttpServletRequest) {
    
    
            requestWrapper = new BodyReaderHttpServletRequestWrapper(request);
        }

        //打印请求信息
        //printRequest(requestWrapper);

        //获取图标不需要验证签名
        if (StringUtils.equals(FAVICON, request.getRequestURI())) {
    
    
            filterChain.doFilter(request, response);
            return;
        }

        //校验头部参数是否有效
        boolean isValid = SignUtils.verifyHeaderParams(requestWrapper);
        if (isValid) {
    
    
            //获取全部参数(包括URL和Body上的)
            SortedMap<String, String> allParams = HttpUtils.getAllParams(requestWrapper);

            /**
             * ppSecret需要自己业务去获取,它的作用主要是区分不同客户端app。
             * 并且利用获取到的appSecret参与到sign签名,保证了客户端的请求签名是由我们后台控制的,
             * 我们可以为不同的客户端颁发不同的appSecret。
             */
            //根据调用传递的appId获取对应的appSecret(应用密钥)
            String appSecret = getAppSecret(allParams.get(Constant.APP_ID));

            //appSecret(应用密钥)存在
            if (StringUtils.isNotEmpty(appSecret)) {
    
    
                //将调用方应用id对应的应用密钥与请求参数合成指定
                boolean isSigned = SignUtils.verifySignature(allParams, appSecret);

                if (isSigned) {
    
    
                    log.info("签名通过");
                    filterChain.doFilter(request, response);
                    return;
                }
            }
        }
        log.info("签名参数校验出错");
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=utf-8");

        PrintWriter out = response.getWriter();
        JSONObject resParam = new JSONObject();
        resParam.put("msg", "签名参数校验出错");
        resParam.put("success", "false");
        out.append(resParam.toJSONString());
    }

    /**
     * 打印请求信息
     * @param request
     */
    private void printRequest(HttpServletRequest request) {
    
    
        BodyReaderHttpServletRequestWrapper requestWrapper = null;
        if (request instanceof BodyReaderHttpServletRequestWrapper) {
    
    
            requestWrapper = (BodyReaderHttpServletRequestWrapper) request;
        }

        JSONObject requestJ = new JSONObject();
        JSONObject headers = new JSONObject();
        Collections.list(request.getHeaderNames())
                .stream()
                .forEach(name -> headers.put(name, request.getHeader(name)));
        requestJ.put("headers", headers);
        requestJ.put("parameters", request.getParameterMap());
        requestJ.put("body", requestWrapper.getBody());
        requestJ.put("remote-user", request.getRemoteUser());
        requestJ.put("remote-addr", request.getRemoteAddr());
        requestJ.put("remote-host", request.getRemoteHost());
        requestJ.put("remote-port", request.getRemotePort());
        requestJ.put("uri", request.getRequestURI());
        requestJ.put("url", request.getRequestURL());
        requestJ.put("servlet-path", request.getServletPath());
        requestJ.put("method", request.getMethod());
        requestJ.put("query", request.getQueryString());
        requestJ.put("path-info", request.getPathInfo());
        requestJ.put("context-path", request.getContextPath());

        log.info("Request-Info: " + JSON.toJSONString(requestJ, SerializerFeature.PrettyFormat));
    }


    /**
     * 获取appId对应的secret,假数据
     *
     * @param appId 应用id
     * @return
     */
    public String getAppSecret(String appId) {
    
    
        Map<String, String> map = new HashMap<>();

        map.put("zs001", "asd123fhg3b7fgh7dfg");
        map.put("ls001", "hghfgh123btgfyh1212");

        return map.get(appId);
    }
}

上面是一个签名过滤器,其中的appSecret(应用密钥)需要根据自己的业务去获取。

  • 通过密钥可以为不同的客户端(调用方) 分配 不同的appSecret,来区分不同客户端app(调用方)。
  • 将获取到的appSecret 参与到sign(签名)的生成,保证了客户端的请求签名是由我们后台控制的。

2.3.签名工具类

@Slf4j
public class SignUtils {
    
    
    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 校验Header上的参数-验证是否传入值
     * <p>
     * 有个很重要的一点,就是对此请求进行时间验证,如果大于10分钟表示此链接已经超时,防止别人来到这个链接去请求。这个就是防止盗链。
     *
     * @param request
     * @return
     */
    public static boolean verifyHeaderParams(HttpServletRequest request) {
    
    
        //应用id
        String appId = request.getHeader(Constant.APP_ID);
        if (StringUtils.isEmpty(appId)) {
    
    
            return false;
        }

        //时间戳,增加链接的有效时间,超过阈值,即失效
        String timeStamp = request.getHeader(Constant.TIME_STAMP);
        if (StringUtils.isEmpty(timeStamp)) {
    
    
            return false;
        }

        //调用方传递的签名
        String signature = request.getHeader(Constant.SIGN);
        if (StringUtils.isEmpty(signature)) {
    
    
            return false;
        }

        // 临时流水号/随机串 ,至少为10位 ,有效期内防重复提交
        String nonce = request.getHeader(Constant.NONCE);
        if (StringUtils.isEmpty(nonce)) {
    
    
            return false;
        }

        //毫秒
        long diff = System.currentTimeMillis() - Long.parseLong(timeStamp);
        //大于10分钟
        if (diff > 1000 * 60 * 10) {
    
    
            return false;
        }

        return true;
    }

    /**
     * 将所有请求参数与应用密钥appSecret进行排序加密后生成签名(signature) 然后与 调用方法传递的签名(signature)进行比较
     *
     * @param params    根据key升序排序的后所有请求参数
     * @param appSecret 应用id对应的应用密钥
     * @return 签名比较结构 true为签名正确
     */
    public static boolean verifySignature(SortedMap<String, String> params, String appSecret) {
    
    
        //调用方传过来的签名
        String paramSignature = params.get(Constant.SIGN);
        log.info("调用方传过来的Sign:{}", paramSignature);
        if (params == null || StringUtils.isEmpty(appSecret)) {
    
    
            return false;
        }

        //将调用方的请求参数 与 应用密钥 按签名规则处理后生成的签名
        String signature = generateSignature(params, appSecret);
        log.info("后端生成的Sign:{}", signature);

        //比较调用方传的签名 与 后台生成的签名
        return StringUtils.isNotEmpty(signature) && StringUtils.equals(paramSignature, signature);
    }

    /**
     * 所有的参数与应用密钥appSecret 进行排序加密后生成签名
     *
     * @param sortedMap 根据key升序排序的后所有请求参数
     * @param appSecret 应用id对应的应用密钥
     * @return 生成接口签名
     */
    public static String generateSignature(SortedMap<String, String> sortedMap, String appSecret) {
    
    
        //先要去掉 前端求参数传过来的 里的 signature
        sortedMap.remove(Constant.SIGN);

        //进行key,value拼接
        // e.g "key1value1key2value2"
        StringBuilder plainText = new StringBuilder();
        for (Map.Entry<String, String> entry : sortedMap.entrySet()) {
    
    
            plainText.append(entry.getKey()+ entry.getValue());
        }
        //拼接应用密钥 appSecret
        plainText.append(appSecret);

        //摘要
        String digest = plainText.toString();

        //将digest 转换成UTF-8 的 byte[] 后 使用MD5算法加密,最后将生成的md5字符串转换成大写
        try {
    
    
            return DigestUtils.md5Hex(StringUtils.getBytes(digest, "UTF-8")).toUpperCase();
        } catch (UnsupportedEncodingException e) {
    
    
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 上面的流程中,会有个额外的安全处理,防止盗链,我们可以让链接有失效时间
     * 而利用nonce参数,可以防止重复提交,在签名验证成功后,判断是否重复提交,原理就是结合redis,判断是否已经提交过
     *
     * @param appId     应用id
     * @param timeStamp 13位时间戳
     * @param nonce     临时流水号/随机串 ,至少为10位 ,有效期内防重复提交
     * @param signature 接口签名
     * @return 是否重复请求
     */
    public boolean isReplayAttack(String appId, String timeStamp, String nonce, String signature) {
    
    
        StringBuilder redisKey = new StringBuilder();
        redisKey.append("IS_REPLAY_ATTACK").append(":")
                .append(Constant.APP_ID).append(":").append(appId)
                .append(Constant.TIME_STAMP).append(":").append(timeStamp)
                .append(Constant.NONCE).append(":").append(nonce)
                .append(Constant.SIGN).append(":").append(signature);

        Object value = redisTemplate.opsForValue().get(redisKey);

        if (value != null && StringUtils.equals(signature, value.toString()))
            return false;
        else
            redisTemplate.opsForValue().set(redisKey, signature, 1000 * 50);
        return false;
    }
}

  • verifyHeaderParams():用于验证调用方是否传入接口签名需要使用的字段值

    这个方法有个很重要的一点,就是对此请求进行时间验证如果大于10分钟表示此链接已经超时,防止别人来到这个链接去请求。 这个就是防止盗链

  • generateSignature() : 将所有请求参数 与 应用密钥appSecret进行排序加密
  • verifySignature() : 将所有请求参数与应用密钥appSecret进行排序加密后生成签名(sign)然后与 调用方法传递的签名(sign)进行比较, 并返回比较结果
  • isReplayAttack(): 在签名验证成功后,而利用 nonce参数+ redis的超时机制,判断是否重复提交(本文没有用到)

2.4.Http请求工具类

获取全部参数(包括URL和Body上的)

public class HttpUtils {
    
    
    /**
     * 获取全部参数(包括URL和Body上的)
     * 
     * @param request
     * @return
     */
    public static SortedMap<String, String> getAllParams(HttpServletRequest request) throws IOException {
    
    
        SortedMap<String, String> sortedMap = new TreeMap<>();
        //获取Header上的参数
        //应用id
        String appId = request.getHeader(Constant.APP_ID);
        sortedMap.put(Constant.APP_ID, appId);

        //时间戳,增加链接的有效时间,超过阈值,即失效
        String timeStamp = request.getHeader(Constant.TIME_STAMP);
        sortedMap.put(Constant.TIME_STAMP, timeStamp);

        //获取调用方的签名
        String sign = request.getHeader(Constant.SIGN);
        sortedMap.put(Constant.SIGN, sign);

        // 临时流水号,防止重复提交
        String nonce = request.getHeader(Constant.NONCE);
        sortedMap.put(Constant.NONCE, nonce);

        //请求路径: 如 http:localhost:8080/signTest/user/info
        String url = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + request.getServletPath();
        sortedMap.put(Constant.REQ_URL, url);

        //获取parameters(对应@RequestParam)
        Map<String, String[]> requestParams = null;
        if (!CollectionUtils.isEmpty(request.getParameterMap())) {
    
    
            requestParams = request.getParameterMap();

            //获取GET请求参数,以键值对形式保存
            for (Map.Entry<String, String[]> entry : requestParams.entrySet()) {
    
    //{username:[xx],password:[xx]}
                sortedMap.put(entry.getKey(), entry.getValue()[0]);
            }
        }

        // 分别获取了request inputstream中的body信息、parameter信息
        //获取body(对应@RequestBody)
        if (request instanceof BodyReaderHttpServletRequestWrapper) {
    
    
            BodyReaderHttpServletRequestWrapper requestWrapper = (BodyReaderHttpServletRequestWrapper) request;
            try {
    
    
                JSONObject data = JSONObject.parseObject(requestWrapper.getBody());
                //获取POST请求的JSON参数,以键值对形式保存
                for (Map.Entry<String, Object> entry : data.entrySet()) {
    
    
                    sortedMap.put(entry.getKey(), entry.getValue().toString());
                }
            } catch (JSONException e) {
    
    
                e.printStackTrace();
            }
        }
        return sortedMap;
    }
}

2.5.请求包装类

如果Filter或者拦截器中实现接口签名,复杂度会大大的降低,且灵活性增加,可以获取原始的http请求与响应

  • ServletRequest的输入流InputStream 在默认情况只能读取一次,要实现多次读取InputStream,需要继承HttpServletRequestWrapper对请求输入流进行缓存,在Filter替换HttpServletRequest对象。详见上面2.2.签名过滤器

也可以将当前签名方案的实现校验逻辑是在控制层的切面内完成,SpringMVC框架会自动帮我们解析解ServletRequest中的请求数据。

public class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapper {
    
    

    private final String body;

    public BodyReaderHttpServletRequestWrapper(HttpServletRequest request) throws IOException {
    
    
        super(request);
        StringBuilder stringBuilder = new StringBuilder();
        BufferedReader bufferedReader = null;
        try {
    
    
            InputStream inputStream = request.getInputStream();
            if (inputStream != null) {
    
    
                bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
                char[] charBuffer = new char[128];
                int bytesRead = -1;
                while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
    
    
                    stringBuilder.append(charBuffer, 0, bytesRead);
                }
            } else {
    
    
                stringBuilder.append("");
            }
        } catch (IOException ex) {
    
    
            throw ex;
        } finally {
    
    
            if (bufferedReader != null) {
    
    
                try {
    
    
                    bufferedReader.close();
                } catch (IOException ex) {
    
    
                    throw ex;
                }
            }
        }
        body = stringBuilder.toString();
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
    
    
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());
        ServletInputStream servletInputStream = new ServletInputStream() {
    
    
            @Override
            public boolean isFinished() {
    
    
                return false;
            }
            @Override
            public boolean isReady() {
    
    
                return false;
            }
            @Override
            public void setReadListener(ReadListener readListener) {
    
     }
            @Override
            public int read() throws IOException {
    
     return byteArrayInputStream.read(); }
        };
        return servletInputStream;
    }

    @Override
    public BufferedReader getReader() throws IOException {
    
    
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }

    public String getBody() {
    
    
        return this.body;
    }
}

HttpServetRequest读取body只能一次的问题

五.API接口设计补充建议

在这里插入图片描述

1.使用POST作为接口请求方式

一般调用接口最常用的两种方式就是GET和POST。两者的区别也很明显,GET请求会将参数暴露在浏览器URL中,而且对长度也有限制。为了更高的安全性,所有接口都采用POST方式请求。

2.客户端IP白名单

ip白名单是指将接口的访问权限对部分ip进行开放来避免其他ip进行访问攻击

  • 设置ip白名单缺点就是当你的客户端进行迁移后,就需要重新联系服务提供者添加新的ip白名单。
  • 设置ip白名单的方式很多,除了传统的防火墙之外,spring cloud alibaba提供的组件sentinel也支持白名单设置。
  • 为了降低api的复杂度,推荐使用防火墙规则进行白名单设置。

3. 单个接口针对ip限流

限流是为了更好的维护系统稳定性。

  • 使用redis进行接口调用次数统计ip+接口地址作为key,访问次数作为value每次请求value+1设置过期时长来限制接口的调用频率。

4. 记录接口请求日志

记录请求日志,快速定位异常请求位置,排查问题原因。(如:用aop来全局处理接口请求)

5. 敏感数据脱敏

在接口调用过程中,可能会涉及到订单号等敏感数据,这类数据通常需要脱敏处理

  • 最常用的方式就是加密。加密方式使用安全性比较高的RSA非对称加密。 非对称加密算法有两个密钥,这两个密钥完全不同但又完全匹配只有使用匹配的一对公钥和私钥,才能完成对明文的加密和解密过程。

6.幂等性问题

幂等性是指: 任意多次请求的执行结果和一次请求的执行结果所产生的影响相同

  • 说的直白一点就是查询操作无论查询多少次都不会影响数据本身,因此查询操作本身就是幂等的。
  • 但是新增操作,每执行一次数据库就会发生变化,所以它是非幂等的。

幂等问题的解决有很多思路,这里讲一种比较严谨的。

  • 提供一个生成随机数的接口,随机数全局唯一。调用接口的时候带入随机数。
  • 第一次调用,业务处理成功后,将随机数作为key,操作结果作为value,存入redis,同时设置过期时长。
  • 第二次调用,查询redis,如果key存在,则证明是重复提交,直接返回错误。

7.版本控制

一套成熟的API文档,一旦发布是不允许随意修改接口的。这时候如果想新增或者修改接口,就需要加入版本控制,版本号可以是整数类型,也可以是浮点数类型。

  • 一般接口地址都会带上版本号,http://ip:port//v1/list , http://ip:port//v2/list

8.响应状态码规范

一个牛逼的API,还需要提供简单明了的响应值,根据状态码就可以大概知道问题所在。我们采用http的状态码进行数据封装,例如200表示请求成功,4xx表示客户端错误,5xx表示服务器内部发生错误。

状态码设计参考如下:

public enum CodeEnum {
    
    
    // 根据业务需求进行添加
    SUCCESS(200,"处理成功"),
    ERROR_PATH(404,"请求地址错误"),
    ERROR_SERVER(505,"服务器内部发生错误");
    
    private int code;
    private String message;
    
    CodeEnum(int code, String message) {
    
    
        this.code = code;
        this.message = message;
    }

    public int getCode() {
    
       return code;   }
    public void setCode(int code) {
    
       this.code = code;  }

    public String getMessage() {
    
     return message;  }
    public void setMessage(String message) {
    
       this.message = message;    }
}

9.统一响应数据格式

为了方便给客户端响应,响应数据会包含三个属性,状态码(code),信息描述(message),响应数据(data)。客户端根据状态码及信息描述可快速知道接口,如果状态码返回成功,再开始处理数据。

public class Result implements Serializable {
    
    
    private static final long serialVersionUID = 793034041048451317L;

    private int code;
    private String message;
    private Object data = null;

    public int getCode() {
    
    
        return code;
    }
    public void setCode(int code) {
    
    
        this.code = code;
    }

    public String getMessage() {
    
    
        return message;
    }
    public void setMessage(String message) {
    
    
        this.message = message;
    }

    public Object getData() {
    
    
        return data;
    }

    /**
     * 放入响应枚举
     */
    public Result fillCode(CodeEnum codeEnum){
    
    
        this.setCode(codeEnum.getCode());
        this.setMessage(codeEnum.getMessage());
        return this;
    }

    /**
     * 放入响应码及信息
     */
    public Result fillCode(int code, String message){
    
    
        this.setCode(code);
        this.setMessage(message);
        return this;
    }

    /**
     * 处理成功,放入自定义业务数据集合
     */
    public Result fillData(Object data) {
    
    
        this.setCode(CodeEnum.SUCCESS.getCode());
        this.setMessage(CodeEnum.SUCCESS.getMessage());
        this.data = data;
        return this;
    }
}

10.接口文档

一个好的API还少不了一个优秀的接口文档。接口文档的可读性非常重要,虽然很多程序员都不喜欢写文档,而且不喜欢别人不写文档。为了不增加程序员的压力,推荐使用swagger2或其他接口管理工具,通过简单配置,就可以在开发中测试接口的连通性,上线后也可以生成离线文档用于管理API

11.生成签名sign的详细步骤

结合案例详细说明怎么生成签名signature(写完上面的博客后,得出的感悟

  • 第1步: 将所有参数(注意是所有参数,包括appId,timeStamp,nonce),除去sign本身,以及值是空的参数,按key名升序排序存储

  • 第2步: 然后把排序后的参数按 key1value1key2value2…keyXvalueX的方式拼接成一个字符串。

    (这里的参数和值必须是传输参数的原始值,不能是经过处理的,如不能将&quot;转成后再拼接)

  • 第3步: 把分配给调用方的密钥secret拼接在第2步得到的字符串最后面

    即: key1value1key2value2…keyXvalueX + secret

  • 第4步: 计算第3步字符串的md5值(32位),然后转成大写,最终得到的字符串作为签名sign

  • 即: Md5(key1value1key2value2…keyXvalueX + secret) 转大写

举例:
假设传输的数据是

http://www.xxx.com/openApi?sign=sign_value&k1=v1&k2=v2&method=cancel&k3=&kX=vX

请求头是

appId:zs001
timeStamp:1612691221000
sign:2B42AAED20E4B2D5BA389F7C344FE91B
nonce:1234567890

(实际情况最好是通过post方式发送)其中sign参数对应的sign_value就是签名的值。

  • 第一步:拼接字符串。

    首先去除sign参数本身,然后去除值是空的参数k3,剩下appId=zs001&timeStamp=1612691221000&nonce=1234567890&k1=v1&k2=v2&&method=cancel&kX=vX,然后按参数名字符升序排序appId=zs001&k1=v1&k2=v2&kX=vX&method=cancel&nonce=1234567890&timeStamp=1612691221000

  • 第二步:将参数名和值的拼接

    appIdzs001k1v1k2v2kXvXmethodcancelnonce1234567890timeStamp1612691221000

  • 第三步:在上面拼接得到的字符串前加上密钥secret

    假设是miyao,得到新的字符串appIdzs001k1v1k2v2kXvXmethodcancelnonce1234567890timeStamp1612691221000miyao

  • 第四步:然后将这个字符串进行md5计算

    假设得到的是abcdef,然后转为大写,得到ABCDEF这个值作为签名sign

注意,计算md5之前调用方需确保签名加密字符串编码与提供方一致,如统一使用utf-8编码或者GBK编码,如果编码方式不一致则计算出来的签名会校验失败。

上面说的请求录音可拼可不拼接,主要还是为了增强签名的复杂性

12.1.什么是token?

Token是什么?
token即 访问令牌access token用于接口中标识接口调用者的身份、凭证,减少用户名和密码的传输次数。 一般情况下客户端(接口调用方)需要先向服务器端申请一个接口调用的账号,服务器会给出一个appId和一个appSecret(appSecret用于参数签名使用),

注意appSecret保存到客户端,需要做一些安全处理,防止泄露。

  • Token的值一般是UUID,服务端生成Token后需要将token做为key,将一些和token关联的信息作为value保存到缓存服务器中(redis),当一个请求过来后,服务器就去缓存服务器中查询这个Token是否存在,存在则调用接口,不存在返回接口错误,一般通过拦截器或者过滤器来实现。

Token分为两种

  1. API Token(接口令牌): 用于访问不需要用户登录的接口,如登录、注册、一些基本数据的获取等。获取接口令牌需要拿appId、timestamp和sign来换,sign=加密(参数1+…+参数n+timestamp+key)
  2. USER Token(用户令牌): 用于访问需要用户登录之后的接口,如:获取我的基本信息、保存、修改、删除等操作。获取用户令牌需要拿用户名和密码来换

    挺好的一篇文章 Java 生鲜电商平台 - API 接口设计之 token、timestamp、sign 具体架构与实现

12.2.Token+签名(有用户状态的接口签名)

上面讲的接口签名方式都是无状态的,在APP开放API接口的设计中,由于大多数接口涉及到用户的个人信息以及产品的敏感数据,所以要对这些接口进行身份验证,为了安全起见让用户暴露的明文密码次数越少越好,然而客户端与服务器的交互在请求之间是无状态的,也就是说,当涉及到用户状态时,每次请求都要带上身份验证信息(令牌token)。

1.Token身份验证

  1. 用户登录向服务器提供认证信息(如账号和密码),服务器验证成功后返回Token给客户端;
  2. 客户端将Token缓存在本地,后续每次发起请求时,都要携带此Token
  3. 服务端检查Token的有效性,有效则放行,无效(Token错误或过期)则拒绝。

弊端Token被劫持,伪造请求和篡改参数。

2.Token+签名验证

  • 与上面接口签名规则一样,为客户端分配appSecret(密钥,用于接口加密,不参与传输)将appSecret和所有请求参数组合成一个字符串,根据签名算法生成签名值,发送请求时将签名值一起发送给服务器验证。这样,即使Token被劫持,对方不知道appSecret和签名算法,就无法伪造请求和篡改参数并且有了token后也能正确的获取到用户的状态

登陆和退出请求
在这里插入图片描述
后续请求
客户端: 与上面接口签名规则一样类似,把appId改为token即可。
在这里插入图片描述

六.基于SpringBoot的Aop+自定义注解的方式实现接口签名源码

点击这里跳转过去

七.总结

优点:

  • 用接口签名的方式保护开发接口可以做到防止别人篡改请求,或者模拟请求。

缺点:

  • 缺少对数据自身的安全保护,请求的参数和返回的数据都是有可能被别人拦截获取的,而这些数据又是明文的,所以只要被拦截,就能获得相应的业务数据

猜你喜欢

转载自blog.csdn.net/qq877728715/article/details/113555907