如何安全的开放后端接口

前言

当今互联网Web各种应用H5、Android、ios、web、小程序等开发时大都采用前后端分离架构,公司为了商业变现会开放自己系统接口给其它公司使用。例如: 调用微信支付。

既然涉及到前后端分离,前端页面调用后端API接口,那么接口的安全设计是非常重要的一项工作。项目的架构师在项目布局过程中,会着重考虑安全,最常见的安全问题就是,用户在移动端提交数据向后端传输,黑客在传输过程中拦截提交的数据,进行篡改,进而达到伪造请求数据的目的。

例如前端提交金额,商品编号信息,黑客中途拦截,修改成低价商品,然后请求下单,早年间国内某电商技术不成熟时,抓包分析下单是很常见的。这时如果我们对一些常规的项目可以通过请求数据报文进行签名、加密、加盐、加时间戳、后端根据数据再次加密,与报文中的签名进行对比是否一致来控制接口安全,这种做法在大厂项目中也是常用手法。


什么是加密解密

  • 加密:数据加密的基本过程,就是对原来为明文,用户输入的数据通过某种处理,变成一串不可直接提取信息的代码,类似于英文字母加阿拉伯数字组合,通常称之为 密文。在战争年代的电报发报加密成密文,对方电台人员收到电文,根据约定的密码本进行破译便可得到明文,这就是为什么密码本对一个军队如此重要。

  • 解密:加密的逆过程,也就是破译电报。


常见的加密算法

加密技术通常分为三大类:对称式非对称式散列算法

  • 对称式:通俗的说就是锁上一把锁与打开这把锁,用的都是同一样一把钥匙。常见的对称加密算法有:DES3DESAES

  • 非对称式:俗名公开秘钥加密算法,它需要一对代码,一个为公钥 (public key)、另一个为私钥(private key) 加密解密用的不是一个秘钥,所以被称之非对称加密

    • 使用公钥对明文加密,有且只有对应的私钥才能解开密文。
    • 使用私钥对明文加密,有且只有对应的公钥才能解开密文。
    • 大多数做法:公钥加密,私钥解密,公钥会在加密前发放给解密方。

    例子:Git 中ssh连接Github,本地电脑生成public key,与private key,将public key提前配置到GitHub账户中,private key留在本地,上传文件时Git便会自动识别认证身份。

常见的非对称性加密算法:RSADSA

  • 散列算法:主要用于验证,防止信息被修。具体用途如:文件校验、数字签名、鉴权协议。

常见的Hash散列算法:MD5SHA1SHA256HMAC等等

  • MD5: MD5是一种不可逆的加密算法,目前是最牢靠的加密算法之一,尚没有能够逆运算的程序被开发出来,它对应任何字符串都可以加密成一段唯一的固定长度的代码。

其他算法介绍查看连接详情

散列算法示意图

使用MD5算法开放接口加密验签实现

需求分析:

  1. 外部应用调用接口,做到极简丝滑调用。
  2. 接口提供方系统不能影响原有业务。
  3. 对接口需求方提交的数据进行校验,若不合法在接口被请求前就应终止这一次请求。
  4. 符合主流大厂接口开放方式。

实现思路:

  • 接口提供方给接口需求方也就是第三方公司发放appid、secret,并要求严格保管。在系统内新建一个合作公司表,使用UUID生成appid与secret,对合作公司进行增删改查。简单 在此文章中略
  • 接口需求方使用约定的MD5算法将appid应用唯一识别、secret秘钥、timestamp时间戳、nonce随机数、业务参数、生成sign签名并一起传递给接口提供方。
  • 接口提供方接收获取appid、secret、timestamp、nonce、并逐个判断是否为空,为空就停止请求,并给第三方友好提示。
  • 接口提供方获取第三方公司提交的timestamp与当前系统时间做对比,如果差值大于120秒,则timestamp无效,如果差值小于120秒,则timestamp有效。目的是防止过期的提交。
  • 根据第三方提交的appid查询数据库内secret,与提交的secret进行对比,这一步可以根据appid判断权限 高级做法
  • 接口提供方获取第三方公司提交的nonce,比较redis中存储的nonce,不一致则通过。防止暴力请求接口。
  • 接口提供方将获取的appid、secret、timestamp、nonce、业务参数通过MD5算法运算得到sign2,与第三方公司提交的sign对比,如果不一致则为不合法请求。
  • 将nonce存入redis,过期时间设置为120秒。

上代码

pom.xml 引入依赖

 
            
                org.springframework.boot
                spring-boot-starter-web
            
            
                org.projectlombok
                lombok
                true
            
            
                org.springframework.boot
                spring-boot-starter-test
                test
            
            
            
                org.apache.shiro
                shiro-spring
                1.5.1
            
            
                com.auth0
                java-jwt
                3.10.1
            
            
            
            
                com.alibaba
                fastjson
                1.2.75
            
            
                org.apache.commons
                commons-lang3
            
            
            
                org.apache.commons
                commons-text
                1.8
            
        
    
        
            
                org.springframework.boot
                spring-boot-maven-plugin
                
                    
                        
                            org.projectlombok
                            lombok
                        
                    
                
            
        
    

MD5签名算法

public class MD5 {

    /**
     * 生成 MD5
     * @param data 待处理数据
     * @return MD5结果
     */
    public static String md5(String data) {
        StringBuilder sb = null;
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] array = md.digest(data.getBytes("UTF-8"));
            sb = new StringBuilder();
            for (byte item : array) {
        sb.append(Integer.toHexString((item & 0xFF) | 0x100).substring(1, 3));
            }
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return sb.toString().toUpperCase();
    }
}

签名是否一致验证工具

/**
 * MD5 sign签名校验宇生成工具
 */
public class GenerateSignatureUtil {

    public static final String FIELD_SIGN = "sign";

    /**
     * 判断签名是否正确,必须包含sign字段,否则返回false。
     * @param data Map类型数据
     * @param key  API密钥
     * @return 签名是否正确
     * @throws Exception
     */
    public static boolean isSignatureValid(Map data, String key){
        if (!data.containsKey(FIELD_SIGN)) {
            return false;
        }
        String sign = data.get(FIELD_SIGN);
        return generateSignature(data, key).equals(sign);
    }
    public static String generateSignature(final Map data, String key) {
        try {
            Set keySet = data.keySet();
            String[] keyArray = keySet.toArray(new String[keySet.size()]);
            Arrays.sort(keyArray);
            StringBuilder sb = new StringBuilder();
            for (String k : keyArray) {
                if (k.equals(FIELD_SIGN)) {
                    continue;
                }
                // 参数值为空,则不参与签名
                if (data.get(k).trim().length() > 0)  {
                   sb.append(k).append("=").append(data.get(k).trim()).append("&");
                }
            }
            sb.append("key=").append(key);
            return MD5.md5(sb.toString());
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "";
    }
}

错误信息提示工具类

/**
 * 客户端工具类
 * @author 
 */
public class ServletUtils {


     // 获取request
    public static HttpServletRequest getRequest() {
        return getRequestAttributes().getRequest();
    }
    // 获取ServletRequestAttributes
    public static ServletRequestAttributes getRequestAttributes() {
        RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
        return (ServletRequestAttributes) attributes;
    }
    /**
     * 将字符串渲染到客户端
     * @param response 渲染对象
     * @param string   待渲染的字符串
     * @return null
     */
    public static String renderString(HttpServletResponse response, String string) {
        try {
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(string);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
  }

重写WebMvcConfigurer

/**
 * @Author 真香
 * @Date 2021/4/20 16:30
 * @Version 1.0
 */

@Slf4j
@Configuration
public class OpenSignWebMvcConfig  implements WebMvcConfigurer {
    @Autowired
    private SignAuthInterceptor signAuthInterceptor;
    @Autowired
    private OpenSignProperties openSignProperties;
    // 拦截器配置
    private OpenSignInterceptorProperties interceptorConfig;
    // 注入spring 容器
    @Bean
    public SignAuthInterceptor signAuthInterceptor () {
        return new SignAuthInterceptor();
    }
    @PostConstruct
    public void init () {
        interceptorConfig = openSignProperties.getInterceptor();
        log.debug("openSignProperties:{}", JSON.toJSONString(interceptorConfig));
    }
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册签名拦截器
        if (interceptorConfig.getSign().isEnable()) {
            registry.addInterceptor(signAuthInterceptor())
                    .addPathPatterns(interceptorConfig.getSign().getIncludePaths())
                    .excludePathPatterns(interceptorConfig.getSign().getExcludePaths());
        }
    }
}

application.yml配置文件


server:
   port: 9999
   ######################## Spring Shiro start ########################
   shiro:
       # 是否启用
      enable: true
       # 权限配置
      anon:
          # 排除登录登出
         - /login,/logout,
          # 排除静态资源
         - /static/**,/templates/**
          # 排除actuator
         - /actuator/**
         # 排除首页, 不再开放此页面
         #      - /,/welcome.html
          # 排除测试路径
         - /hello/world,
       # 多行字符串权限配置
      filter-chain-definitions: |
         /resource/**=anon
         /upload/**=anon
         /verificationCode/**=anon
         /enum=anon
      # 权限配置
      permission:
          # 排除登陆登出相关
         - urls:
           permission: anon
######################## Spring Shiro end ##########################
##############################open sign start ######################
open-sign:
    # Filter配置
   filter:
      request:
         enable: true
         url-patterns: /*
         order: 1
         async: true
      xss:
         enable: true
         url-patterns: /*
         order: 2
         async: true
      repeatedlyread:
         enable: true
         url-patterns: /*
         order: 2
         async: true
    # 拦截器配置
   interceptor:
      # 配置需要进行签名拦截的接口地址
      sign:
         enable: true
         include-paths:

SignAuthInterceptor 最重要的拦截器拦截请求

@Slf4j
public class SignAuthInterceptor implements HandlerInterceptor {
    private static final String NONCE_KEY_STR = "nonce-";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        Map map = request.getParameterMap();
        // 从数组中取出参数放入Map中
        Map param = new ConcurrentHashMap<>(10);
        for (Map.Entry entry  : map.entrySet()) {
            String key = entry.getKey();
            String[] values = entry.getValue();
            for (int i = 0; i < values.length; i++) {
                String value = values[i];
                param.put(key,value);
            }
        }
        //  1、获取请求参数appId
        String appid = param.get("appid");
        if (StringUtils.isBlank(appid)) {
            log.info("appid不能为空");
            ServletUtils.renderString(response, JSON.toJSONString(ApiResult.failRequest("appid不能为空")));
            return false;
        }
        // 2、获取请求参数secret
        String secret =request.getParameter("secret");
        if (StringUtils.isBlank(secret)){
            log.info("secret不能为空...........");
            ServletUtils.renderString(response, JSON.toJSONString(ApiResult.failRequest("secret不能为空")));
            return false;
        }
        /**
        3、验证secret权限、来源是否合法
         *  此处可以用appId,条件为 已开启,未封禁等进行数据库合作机构表查询,有可能已经终止合作禁止了此有用访问,
         *  业务上达到一定条件的可以根据appId分配权限,选择不同的接口能力进行开放
         */
        TDrivingCooperation drivingCooperationByPartnerkey = drivingCooperationService.getDrivingCooperationByPartnerkey(partnerkey);
        if (drivingCooperationByPartnerkey == null || drivingCooperationByPartnerkey.getStatus().equals(StatusEnum.DISABLE.getCode())) {
            ServletUtils.renderString(response, JSON.toJSONString(ApiResult.failRequest("partnerkey无法查询到合作公司信息或已被封禁")));
            return false;
        }
        // 获取secret 与数据库值对比,判断请求来源是否合法
       if (!secret.equals(drivingCooperationByPartnerkey.getSecret())) {
            log.debug("secret与接口提供方不一致...........");
            System.out.println("secret与接口提供方不一致...........");
            ServletUtils.renderString(response, JSON.toJSONString(ApiResult.failRequest("secret与接口提供方不一致")));
            return false;
        }
        // 4、 获取请求参数timestamp 时间戳,
        String timestamp = request.getParameter("timestamp");
        if (StringUtils.isBlank(timestamp)){
            log.info("timestamp不能为空...........");
            ServletUtils.renderString(response, JSON.toJSONString(ApiResult.failRequest("timestamp不能为空")));
            return false;
        }
        /** 5、 防止过期时间的提交
         * 从前端传递的timestamp 与服务器端当前系统时间之差大于120s,则此次请求的timestamp无效
         *  留出短时间考虑网络问题提交速度慢,若时间过长中间时间足以挟持篡改参数,所以折中考虑了120秒
         */
        Long time = System.currentTimeMillis()/1000;
        if (Math.abs(Long.valueOf(timestamp)-time)>120) {
            log.info("timestamp失效...........");
            ServletUtils.renderString(response, JSON.toJSONString(ApiResult.failRequest("timestamp失效")));
            return false;
        }
        // 6、获取请求参数nonce随机数,防止重复的暴力请求
        String nonce = param.get("nonce");
        if (StringUtils.isBlank(nonce)) {
            log.debug("nonce不能为空...........");
            ServletUtils.renderString(response, JSON.toJSONString(ApiResult.failRequest("nonce不能为空")));
            return false;
        }
        /**
         *  如果设计得规范一些可以防止重复提交,我这因为是小项目,Demo演示就不做redis缓存随机数了
         *   流程:1、获取当前提交的随机数,作为key前往redis 查询,若有值则为重复提交
         *        2、redis中查询不到结果,将当前随机数作为key,value为随机数,过期时间设置为120s
         */
        // 7、获取请求sign签名参数,
        String sign = param.get("sign");
        if (StringUtils.isBlank(sign)){
            log.info("sign不能为空...........");
            ServletUtils.renderString(response, JSON.toJSONString(ApiResult.failRequest("sign不能为空")));
            return false;
        }
        //8.通过后台MD5重新签名校验与前端签名sign值比对,确认当前请求数据是否被篡改
        boolean reuslt = GenerateSignatureUtil.isSignatureValid(param, secret);
        if (!reuslt){
            log.debug("sign签名校验失败...........");
            ServletUtils.renderString(response, JSON.toJSONString(ApiResult.failRequest("sign签名校验失败")));
            return false;
        }
        log.info("签名校验通过,放行...........";
        // 获取sign签名,与服务端生成的sign 签名对比
        return true;
    }
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        System.out.println("SignAuthInterceptor postHandle======  ");
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        System.out.println("SignAuthInterceptor afterCompletion======  ");
    }
}

测试接口

/**
 * @Author 真香
 * @Date 2021/4/20 17:01
 * @Version 1.0
 */

@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

    @RequestMapping(value = "/add",method = RequestMethod.POST)
    public ApiResult addUser (User user) {
        log.info("user=="+user);
        return ApiResult.ok(true);
    }

}

以上展示了一些关键代码,还有一些辅助代码因为排版问题未一一展示,后续可以通过仓库地址克隆。

来吧 展示

模拟请求正常 这里采用HttpClient 跟接近真实开发方式
先来一次所有参数都正常的请求

 @Test
    public void testOpenSign() {
        Map params = new ConcurrentHashMap<>(10);
        String secret = "1ae41230bd1b4383a44f1b114ceba13c";
        params.put("appid","6ee781ae6ef4496a");
        // 获取时间戳单位S
        Long timesTamp = System.currentTimeMillis()/1000;
        System.out.println("time ==" + timesTamp);
        params.put("timestamp",String.valueOf(timesTamp));
        params.put("nonce",UUIDUtil.getUuid());
        params.put("secret",secret);
        params.put("name", "张三");
        params.put("address","中国");
        params.put("sex","0");
        // 调用MD5算法加密生成签名
        String signature = GenerateSignatureUtil.generateSignature(params, secret);
        System.out.println("sign = " + signature);
        // 签名加入请求参数
        params.put("sign",signature);
        log.info("开始请求open-sign接口==========:{}",params);
        String result = HttpClientUtil.doPost("http://localhost:9999/user/add", params);
        System.out.println(result);
    }

拿到参数

成功获取请求参数

第三方得到请求返回值

{"code":200,"success":true,"message":"操作成功","data":true,"time":"2021-04-23 08:47:56"}

模拟错误请 这一次我故意去除appid不传

@Test
    public void testOpenSign() {
        Map params = new ConcurrentHashMap<>(10);
        String secret = "1ae41230bd1b4383a44f1b114ceba13c";
        Long timesTamp = System.currentTimeMillis()/1000;
        System.out.println("time ==" + timesTamp);
        params.put("timestamp",String.valueOf(timesTamp));
        params.put("nonce",UUIDUtil.getUuid());
        params.put("secret",secret);
        params.put("name", "张三");
        params.put("address","中国");
        params.put("sex","0");
        // 调用MD5算法加密生成签名
        String signature = GenerateSignatureUtil.generateSignature(params, secret);
        System.out.println("sign = " + signature);
        // 签名加入请求参数
        params.put("sign",signature);
        log.info("开始请求open-sign接口==========:{}",params);
        String result = HttpClientUtil.doPost("http://localhost:9999/user/add", params);
        System.out.println(result);
    }

断点捕获

断点捕获

第三方得到友好返回值

{"code":500000,"message":"appid不能为空","success":false,"time":"2021-04-23 16:55:25"}

以上只使用了appid作为例子,其余的都是大差不差,算法代码已经写好,错了,漏传、篡改都会帮我们校验。

项目中使用了安全框架例如Shiro、SpringSecurity需要提前放开权限校验,否则请求还没有到签名拦截器就被安全框架拦截了,我这项目因为核心是校验签名所以没搭建shiro认证,我们使用签名校验简化了权限校验,不再需要注册账号,通过颁发token方式给第三方公司。

仓库地址:https://gitee.com/JameZhan/check-sign.git
欢迎Issues与PR

写在最后

很长时间没有更新了,也有一些好友来催更。上半年因为业务忙,也自我放松了,过多的时间放在了看书,后期如果有可能会写一些书籍读后感与游玩杂记。

你可能感兴趣的:(如何安全的开放后端接口)