乐趣区

关于spring:如何安全的开放后端接口

前言

当今互联网 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 引入依赖

 <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <optional>true</optional>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <scope>test</scope>
            </dependency>
            <!-- Shiro+JWT start -->
            <dependency>
                <groupId>org.apache.shiro</groupId>
                <artifactId>shiro-spring</artifactId>
                <version>1.5.1</version>
            </dependency>
            <dependency>
                <groupId>com.auth0</groupId>
                <artifactId>java-jwt</artifactId>
                <version>3.10.1</version>
            </dependency>
            <!-- Shiro+JWT end -->
            <!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>fastjson</artifactId>
                <version>1.2.75</version>
            </dependency>
            <dependency>
                <groupId>org.apache.commons</groupId>
                <artifactId>commons-lang3</artifactId>
            </dependency>
            <!-- XSS -->
            <dependency>
                <groupId>org.apache.commons</groupId>
                <artifactId>commons-text</artifactId>
                <version>1.8</version>
            </dependency>
        </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

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<String, String> 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<String, String> data, String key) {
        try {Set<String> 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<String, String[]> map = request.getParameterMap();
        // 从数组中取出参数放入 Map 中
        Map<String,String> param = new ConcurrentHashMap<>(10);
        for (Map.Entry<String, String[]> 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<Boolean> addUser (User user) {log.info("user=="+user);
        return ApiResult.ok(true);
    }

}

以上展现了一些要害代码,还有一些辅助代码因为排版问题未一一展现,后续能够通过仓库地址克隆。

来吧 展现

模仿申请失常 这里采纳 HttpClient 跟靠近实在开发方式
先来一次所有参数都失常的申请

 @Test
    public void testOpenSign() {Map<String, String> 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<String, String> 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/ch…
欢送 Issues 与 PR

写在最初

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

我是顾北,一个集才华与技术的男生,有问题欢送后盾加微信交换。

退出移动版