赞
踩
当今互联网Web各种应用H5、Android、ios、web、小程序等开发时大都采用前后端分离架构,公司为了商业变现会开放自己系统接口给其它公司使用。例如: 调用微信支付。
既然涉及到前后端分离,前端页面调用后端API接口,那么接口的安全设计是非常重要的一项工作。项目的架构师在项目布局过程中,会着重考虑安全,最常见的安全问题就是,用户在移动端提交数据向后端传输,黑客在传输过程中拦截提交的数据,进行篡改,进而达到伪造请求数据的目的。
例如前端提交金额,商品编号信息,黑客中途拦截,修改成低价商品,然后请求下单,早年间国内某电商技术不成熟时,抓包分析下单是很常见的。这时如果我们对一些常规的项目可以通过请求数据报文进行签名、加密、加盐、加时间戳、后端根据数据再次加密,与报文中的签名进行对比是否一致来控制接口安全,这种做法在大厂项目中也是常用手法。
加密:数据加密的基本过程,就是对原来为明文,用户输入的数据通过某种处理,变成一串不可直接提取信息的代码,类似于英文字母加阿拉伯数字组合,通常称之为 密文。在战争年代的电报发报加密成密文,对方电台人员收到电文,根据约定的密码本进行破译便可得到明文,这就是为什么密码本对一个军队如此重要。
解密:加密的逆过程,也就是破译电报。
加密技术通常分为三大类:对称式、非对称式、散列算法。
对称式:通俗的说就是锁上一把锁与打开这把锁,用的都是同一样一把钥匙。常见的对称加密算法有:DES、3DES、AES等
非对称式:俗名公开秘钥加密算法,它需要一对代码,一个为公钥 (public key)、另一个为私钥(private key) 加密解密用的不是一个秘钥,所以被称之非对称加密。
使用公钥对明文加密,有且只有对应的私钥才能解开密文。
使用私钥对明文加密,有且只有对应的公钥才能解开密文。
大多数做法:公钥加密,私钥解密,公钥会在加密前发放给解密方。
例子:Git 中ssh连接Github,本地电脑生成public key,与private key,将public key提前配置到GitHub账户中,private key留在本地,上传文件时Git便会自动识别认证身份。
常见的非对称性加密算法:RSA、DSA 等
常见的Hash散列算法:MD5、SHA1、SHA256、HMAC等等
需求分析:
实现思路:
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/check-sign.git 欢迎Issues与PR
很长时间没有更新了,也有一些好友来催更。上半年因为业务忙,也自我放松了,过多的时间放在了看书,后期如果有可能会写一些书籍读后感与游玩杂记。
我是顾北,一个集才华与技术的男生,有问题欢迎后台加微信交流。
本文由博客一文多发平台 OpenWrite 发布!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。