提供业务给第三方,如何保证自己接口的安全

133 阅读4分钟

1. 背景

出海的业务当中我们会对接很多的商户,有时候我们会提供一个接口给第三方OTA,比如提供接口给第三方OTA用于对方给我们推送可以结算的订单,那如何保证我们接口的安全性是一个核心的问题

2. 一个例子说明保证接口安全的必要性和重要性

现在有个充值的接口,调用后可以给用户增加对应的余额, http://localhost/api/user/recharge?user_id=1001&amount=10

会带来的可能问题:

  • 如果非法用户通过抓包获取到接口参数后,修改user_id 或 amount的值就可以实现给任意账户添加余额的目的

  • 非法用户获取到这个请求的信息之后什么也不改,,直接拿着接口的参数 重复请求这个充值的接口

基于上面的理解就是两个概念:防篡改防重放

3. 解决方案

3.1 https协议传输

http协议是无状态的协议,明文传输的, 服务端无法确定客户端发送的请求是否合法,也不了解请求中的参数是否正确

采用https协议可以将传输的明文进行加密,但是黑客仍然可以截获传输的数据包,进一步伪造请求进行重放攻击。如 果黑客使用特殊手段让请求方设备使用了伪造的证书进行通信,那么https加密的内容也会被解密。防证书伪造可以采用:客户端加签, 服务端验签

3.1 加签验签

整体流程:

  1. 服务方提供一组AppId和appSecret,由客户端保存
  2. 将timestamp、nonce、appId与请求参数一起按照字典排序,使用url键值对的格式拼接成字符串strA
  3. 在strA的最后拼接上appSecret,得到strB
  4. 使用摘要算法对strB进行加密,得到签名sign,和其他参数一起发给服务端
  5. 服务端收到请求后,对接口进行校验
  6. appId 参与本地加密和网络传输,appSecret仅仅作为本地加密使用,不参与网络传输,服务端拿到appId后,从存储介质中获取appSecret,然后采用与客户端相同的签名规则生成服务端签名,最后比较客户端和服务端的签名是否一致

服务端校验的步骤:

  1. 请求时间校验: 判断请求时间是否已经超过允许范围
  2. nonce校验: 判断nonce请求是否已经处理过,一般借助redis实现
  3. 签名校验: 服务端收到请求,根据约定的规则重新签名得到sign

4.完整的代码 Talk is cheap. Show me the code

服务端代码: sourcecode: test_springboot, branch: 接口安全性校验

主要filter:

public class SignFilter implements Filter {
    @Resource
    private RedisUtil redisUtil;

    //从fitler配置中获取sign过期时间
    private Long signMaxTime;

    private static final String NONCE_KEY = "x-nonce-";

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;

        System.out.println(httpRequest.getRequestURI());

        HttpServletRequestWrapper requestWrapper = new SignRequestWrapper(httpRequest);
        //构建请求头
        ClientRequestHeader clientRequestHeader = new ClientRequestHeader();
        clientRequestHeader.setNonce(httpRequest.getHeader("x-Nonce"));
        clientRequestHeader.setSign(httpRequest.getHeader("X-Sign"));
        String header = httpRequest.getHeader("X-Time");
        if (StringUtils.isEmpty(header)) {
            responseFail(httpResponse, ReturnCode.ILLEGAL_HEADER);
            return;
        }
        clientRequestHeader.setTimestamp(Long.parseLong(header));
        //验证请求头是否存在
        if (StringUtils.isEmpty(clientRequestHeader.getSign()) || ObjectUtils.isEmpty(clientRequestHeader.getTimestamp()) || StringUtils.isEmpty(clientRequestHeader.getNonce())) {
            responseFail(httpResponse, ReturnCode.ILLEGAL_HEADER);
            return;
        }

        /*
         * 1.重放验证
         * 判断timestamp时间戳与当前时间是否超过60s(过期时间根据业务情况设置),如果超过了就提示签名过期。
         */
        long now = System.currentTimeMillis() / 1000;

        if (now - clientRequestHeader.getTimestamp() > signMaxTime) {
            responseFail(httpResponse, ReturnCode.REPLAY_ERROR);
            return;
        }

        //2. 判断nonce
        boolean nonceExists = redisUtil.hasKey(NONCE_KEY + clientRequestHeader.getNonce());
        if (nonceExists) {
            //请求重复
            responseFail(httpResponse, ReturnCode.REPLAY_ERROR);
            return;
        } else {
            redisUtil.set(NONCE_KEY + clientRequestHeader.getNonce(), clientRequestHeader.getNonce(), signMaxTime);
        }

        boolean accept;
        SortedMap<String, String> paramMap;
        switch (httpRequest.getMethod()) {
            case "GET":
                paramMap = HttpDataUtil.getUrlParams(requestWrapper);
                accept = SignUtil.verifySign(paramMap, clientRequestHeader);
                break;
            case "POST":
                paramMap = HttpDataUtil.getBodyParams(requestWrapper);
                accept = SignUtil.verifySign(paramMap, clientRequestHeader);
                break;
            default:
                accept = true;
                break;
        }
        if (accept) {
            filterChain.doFilter(requestWrapper, servletResponse);
        } else {
            responseFail(httpResponse, ReturnCode.ARGUMENT_ERROR);
            return;
        }

    }

    private void responseFail(HttpServletResponse httpResponse, ReturnCode returnCode) {
        ResultData<Object> resultData = ResultData.fail(returnCode.getCode(), returnCode.getMessage());
        WebUtils.writeJson(httpResponse, resultData);
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        String signTime = filterConfig.getInitParameter("signMaxTime");
        signMaxTime = Long.parseLong(signTime);
    }
}

辅助的bean

public class ClientRequestHeader {
    /**
     * 客户端传来的签名密钥
     */
    private String sign;
    /**
     * 客户端的时间戳
     */
    private Long timestamp;
    /**
     * 随机的字符串,nonce的意思是仅一次有效的随机字符串,要求每次请求时该参数要保证不同。实际使用用户信息+时间戳+随机数等信息做个哈希之后,作为nonce参数。
     */
    private String nonce;



    public String getSign() {
        return sign;
    }

    public void setSign(String sign) {
        this.sign = sign;
    }

    public Long getTimestamp() {
        return timestamp;
    }

    public void setTimestamp(Long timestamp) {
        this.timestamp = timestamp;
    }

    public String getNonce() {
        return nonce;
    }

    public void setNonce(String nonce) {
        this.nonce = nonce;
    }
}
@Configuration
public class SignFilterConfiguration {

    @Value("${sign.maxTime}")
    private String signMaxTime;

    //filter中的初始化参数
    private Map<String, String> initParametersMap = new HashMap<>();

    @Bean
    public FilterRegistrationBean contextFilterRegistrationBean() {
        initParametersMap.put("signMaxTime", signMaxTime);
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(signFilter());
        registration.setInitParameters(initParametersMap);
        registration.addUrlPatterns("/*");
        registration.setName("SignFilter");
        // 设置过滤器被调用的顺序
        registration.setOrder(1);
        return registration;
    }

    @Bean
    public Filter signFilter() {
        return new SignFilter();
    }
}