天天看点

实现服务器(JAVA)+移动端(HBuilder开发的app)之间的数据加密(AES对称加密)详细过程以及遇到的坑,另附带HttpServletRequestWrapper类使用

由于之前app业务着急上线,再加上以前做的项目都是web项目,且前后端不分离,对数据加密这一块始终没有一个深刻的认识,现在终于回过头来有时间做数据加密这一块,不然数据全都是在互联网上“裸奔”,心里着实没有安全感。言归正传, 开始进入正题。

一、加密前的准备(如果只想了解标题说的重点,可以忽略这第一段直接从第二段开始看)

在进行数据加密之前我知道有几种可选择的加密方式,也可以参照我之前的文章网络信息传输之数据加密【一】网络信息传输之数据加密【二】,都有详细的讲解,md5在web开发一般用于对用户密码的存储,当用户注册时,对用户设定的密码进行md5加密,然后存入数据库,因为md5是不可逆的,所以良好的保证了即使数据库被别人窃取,也无法获取用户的密码信息,只能通过暴力枚举的方式来猜。但是也正是因为其不可逆,所以无法对数据进行还原,用来传输数据不是合适的选择。另外还有Base64,严格来说它并不是加密,而是一种编码,同样通过Base64解码之后即可获得原数据,用在向服务器GET方式提交数据时,提交的数据会以明文的方式展示在url中,用base64编码之后即可避免肉眼就能看懂数据。另外还有其他加密方式一大堆,直接奔向主体,说一下我要用的AES对称加密,当然有对称加密就有非对称加密,而且更安全,但是由于非对称加密比较“重”,加密解密用时相对对称加密来说较长,而我只是传输普通数据,所以选择对称加密中的AES。

二、AES对称加密

它是什么,安全性怎么样这里就不多介绍了,上网搜一大堆,直接讲如何实现服务器(JAVA)和移动端(这里是JavaScript)之间的AES加密,由于一开始我想当然,觉得算法一致,理应得出的加密结果是一致的,然后现实狠狠的抽了我一耳光,我发现网上的例子,大都是讲如何用java实现加密解密,如何用js实现加密解密,但是并没有讲到重点,那就是java加密/解密的js如何解密/加密!

由于在此耗时良久,最终才看到了问题的重点,那就是:跨语言加解密的要求是:AES/CBC/ZeroPadding 128位模式,key和iv一样,编码统一用utf-8。不支持ZeroPadding的就用NoPadding.大家一定要记住这一点,用**AES/CBC/ZeroPadding 128位模式,不支持ZeroPadding的就用NoPadding!**在此要感谢此文章的作者:https://www.cnblogs.com/mao2080/p/10500612.html,让我茅塞顿开。

附上代码,JAVA代码:

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;

import Decoder.BASE64Decoder;
import Decoder.BASE64Encoder;

public class CryptoTest {
		//使用AES-128-CBC加密模式,key需要为16位,iv为偏移量,加上之后增加破解难度
		private static final String key = "1234567890123456";
		private static final String iv = "1234567812345678";
		public static String encrypt(String message){
		    try {
		      Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
		      int blockSize = cipher.getBlockSize();
		      byte[] dataBytes = message.getBytes();
		      //后面为了过滤器加密字节流转String丢失字节的问题,用base64进行编码转换,这里先不用
		      //byte[] dataBytes = new BASE64Decoder().decodeBuffer(message);
		      int plaintextLength = dataBytes.length;
		      //获取要加密的字节数组长度之后,检查长度是否为16位的倍数,不足的加到16位的倍数
		      if (plaintextLength % blockSize != 0) {
		        plaintextLength = plaintextLength + (blockSize - (plaintextLength % blockSize));
		      }
		      //创建一个新的数组,长度为16的倍数
		      byte[] plaintext = new byte[plaintextLength];
		      //复制原数组到新数组,不足位数的补0,也就是比如原数组[12,25,33],新数组[12,25,33,0,0,......]
		      System.arraycopy(dataBytes, 0, plaintext, 0, dataBytes.length);
		      SecretKeySpec keyspec = new SecretKeySpec(key.getBytes(), "AES");
		      IvParameterSpec ivspec = new IvParameterSpec(iv.getBytes());
		      cipher.init(Cipher.ENCRYPT_MODE, keyspec, ivspec);
		      byte[] encrypted = cipher.doFinal(plaintext);
		      //对加密后的字节用BASE64转码,因为直接用new String(encrypted)的方式,会有字节丢失的问题,传输后解密失败
		      return new BASE64Encoder().encode(encrypted);
		    } catch (Exception e) {
		      e.printStackTrace();
		      return null;
		    }
		}
		//可以使用String.trim() 方法去掉返回解密内容中出现在尾部多余的空格
		public static String decrypt(String encrypt){
			if(encrypt == null){
				return "";
			}
		    try{
		      //对数据再用BASE64转字节数组,这样就可以保证和原有的加密的字节数组一致。而使用 encrypt.getBytes()会有字节丢失
		      byte[] encrypted1 = new BASE64Decoder().decodeBuffer(encrypt);
		      Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
		      SecretKeySpec keyspec = new SecretKeySpec(key.getBytes(), "AES");
		      IvParameterSpec ivspec = new IvParameterSpec(iv.getBytes());
		      cipher.init(Cipher.DECRYPT_MODE, keyspec, ivspec);
		      byte[] original = cipher.doFinal(encrypted1);
		      byte[] newOriginal = null;
		      //下面这个for循环本来没有,但是解密后的字符串会有空格,因为不足16位补零的问题,所以会有空格。用来去掉多出的空格
		      //一般来说平常的业务中用String.trim() 方法去掉头部和尾部的空格也没问题,但是遇到比如做聊天室应用,用户就是任性,发送了一堆空格,String.trim()会去掉头尾所有空格,这个消息也就不存在了,强迫症发作非要去掉补上的空格,保留原来的空格。。。。
		      for(int i=0;i<original.length;i++){
		    	  if(original[i] == 0){//当遇到补0时自动结束
		    		  newOriginal = new byte[i];//创建新数组
		    		  System.arraycopy(original, 0, newOriginal, 0, i);//拷贝过去
		    		  break;
		    	  }
		    	  if(i == original.length-1){//一直没遇到补零,就直接复制所有的数据。这里是个坑(标记1),后面说
		    		  newOriginal = new byte[original.length];
		    		  System.arraycopy(original, 0, newOriginal, 0, original.length);
		    		  break;
		    	  }
		      }
		      if(newOriginal == null) return null;
		      String originalString = new String(newOriginal,"utf-8");
		      return originalString;
		    }
		    catch (Exception e) {
		      //e.printStackTrace();
		      return "";
		    }
		}
	    
	    public static void main(String[] args) throws Exception {
	    	System.out.println(encrypt("我爱中国"));
		    System.out.println(decrypt(encrypt("我爱中国")));
	    }

}
//输出
M+++mWGzbQNXNwzpEXQtSw==
我爱中国
           

基本注释上都解释清楚了,接下来讲一讲代码里面那个标记1的坑,代码:

for(int i=0;i<original.length;i++){
   	  if(original[i] == 0){//当遇到补0时自动结束
   		  newOriginal = new byte[i];//创建新数组
   		  System.arraycopy(original, 0, newOriginal, 0, i);//拷贝过去
   		  break;
   	  }
   	  if(i == original.length-1){//一直没遇到补零,就直接复制所有的数据。这里是个坑(标记1),后面说
   		  newOriginal = new byte[original.length];
   		  System.arraycopy(original, 0, newOriginal, 0, original.length);
   		  break;
   	  }
 }
           

本来是没第二个if 的,因为之前看网上讲解,说java不支持ZeroPadding的就用NoPadding,因为ZeroPadding不满16位补0至16位,正好满16位,再补16个0,也就是32位,而NoPadding模式需要自己写代码补齐16位(例子中已实现), 后来我理所当然的想着这下NoPadding和ZeroPadding一样了,结果肯定是不管怎么样都会有补零,所以就没考虑碰不到0的情况,newOriginal 会直接是空数组,结果返回null。

结果项目跑起来之后,大量的数据获取为空,一部分用户登录不上(刚好这部分用户数据获取字节后是16的倍数,没有补0),一开始一直以为是app上传数据的问题,我自己测试一直没问题,用户一直在催,就这样两天后心态差点崩了,后来半夜醒来,来了灵感,排查解密代码,加上了第二个if,至此坑填上了。

接下来说一下app移动端的代码,移动端我是用Hbuilder快速开发的,实现了一部分业务,毕竟免费用了那么久,在这里推荐一下,如果对原生安卓不太懂,学的话花费时间长,如果赶时间,用Hbuilder开发app也是个不错的选择,三天上手。。。

业务数据主要是用js来和服务器交互,加密也是js的Crypto,CryptoJS为JavaScript库,提供了各种各样的加密算法,包括MD5、SHA1、SHA256、AES、Rabbit等,CryptoJS Github地址:https://github.com/brix/crypto-js

先上代码:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" />
    <title></title>
    <script type="text/javascript" src="js/crypto.min.js" ></script>
</head>
<body>
	<div id="encrypt"></div>
	<div id="decrypt"></div>
</body>
<script type="text/javascript" charset="utf-8">
    var key  = CryptoJS.enc.Latin1.parse('1234567890123456');
	var iv   = CryptoJS.enc.Latin1.parse('1234567812345678');
	function myEncrypt(data){
		data = data.toString();
		var encrypted = CryptoJS.AES.encrypt(data,key,{iv:iv,mode:CryptoJS.mode.CBC,padding:CryptoJS.pad.ZeroPadding});
	    return encrypted.toString();
	}
	function myDecrypt(encrypted){
		var decrypted = CryptoJS.AES.decrypt(encrypted,key,{iv:iv,mode:CryptoJS.mode.CBC,padding:CryptoJS.pad.ZeroPadding});
		return decrypted.toString(CryptoJS.enc.Utf8);
	}
	var encryptObj = document.getElementById("encrypt");
	var decryptObj = document.getElementById("decrypt");
	
	encryptObj.innerHTML = myEncrypt("我爱中国");
	decryptObj.innerHTML = myDecrypt(myEncrypt("我爱中国"));
	
</script>
</html>
           

要求:前后端所用key和iv一致,mode和padding也一致,都是用CBC模式,ZeroPadding补齐方式(java用的noPadding),也就是代码里的key,{iv:iv,mode:CryptoJS.mode.CBC,padding:CryptoJS.pad.ZeroPadding},然后结果为:

实现服务器(JAVA)+移动端(HBuilder开发的app)之间的数据加密(AES对称加密)详细过程以及遇到的坑,另附带HttpServletRequestWrapper类使用

加密和解密内容和java的一致,基本上可以判定java加密的数据到了js这里就可以正常解密,反过来也是一样。

加密的事情基本上到这里就结束了,主要在于文章开始提到的,模式用cbc模式,补齐方式用ZeroPadding或noPadding,内容统一utf-8,

实现服务器(JAVA)+移动端(HBuilder开发的app)之间的数据加密(AES对称加密)详细过程以及遇到的坑,另附带HttpServletRequestWrapper类使用
实现服务器(JAVA)+移动端(HBuilder开发的app)之间的数据加密(AES对称加密)详细过程以及遇到的坑,另附带HttpServletRequestWrapper类使用

三、java如何用过滤器拦截数据,并对数据统一解密,然后对返回移动端的数据统一加密

我使用的是过滤器对请求的数据拦截,获取HttpServletRequest里面的参数,但是HttpServletRequest定义就是从客户端传来的信息,默认是无法修改的,也没有提供修改的方法。因为如果你能随便修改,那就不能保证这个数据就是用户递交的原数据。

但是真的不能修改吗,也不是,有一个HttpServletRequest的包装类——>HttpServletRequestWrapper,我们只需要继承HttpServletRequestWrapper类,重写里面的方法,即可实现对HttpServletRequest参数的获取和修改:

import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import java.util.Vector;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
/**
 * 此类重新包装HttpServletRequest请求,使其可以在过滤器中被修改参数(本来是不可以修改参数的)
 */
public class MyHttpServletRequestWrapper extends HttpServletRequestWrapper {
	private Map<String, String[]> params = new HashMap<String, String[]>();
	public MyHttpServletRequestWrapper(HttpServletRequest request) {
		super(request);
        this.params.putAll(request.getParameterMap());
	}
	/**
     * 重载构造函数
     * @param request
     * @param extraParams
     */
    public MyHttpServletRequestWrapper(HttpServletRequest request, Map<String, Object> extraParams) {
        this(request);
        addParameters(extraParams);
    }
 
    public void addParameters(Map<String, Object> extraParams) {
        for (Map.Entry<String, Object> entry : extraParams.entrySet()) {
            addParameter(entry.getKey(), entry.getValue());
        }
    }
 
    /**
     * 重写getParameter,代表参数从当前类中的map获取
     * @param name
     * @return
     */
    @Override
    public String getParameter(String name) {
        String[]values = params.get(name);
        if(values == null || values.length == 0) {
            return null;
        }
        return values[0];
    }
 
    /**
     * 同上
     * @param name
     * @return
     */
    @Override
    public String[] getParameterValues(String name) {
        return params.get(name);
    }
 
    /**
     * 添加参数
     * @param name
     * @param value
     */
    public void addParameter(String name, Object value) {
        if (value != null) {
            if (value instanceof String[]) {
                params.put(name, (String[]) value);
            } else if (value instanceof String) {
                params.put(name, new String[]{(String) value});
            } else {
                params.put(name, new String[]{String.valueOf(value)});
            }
        }
    }

}

           

然后我们在过滤器中,把HttpServletRequest传入我们的**MyHttpServletRequestWrapper **,利用MyHttpServletRequestWrapper 来代替原来的HttpServletRequest:

import java.io.IOException;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.log4j.Logger;

import Decoder.BASE64Encoder;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.myApp.util.Crypto;

public class CryptoFilter implements Filter {
	private static Logger logger = Logger.getLogger(CryptoFilter.class);
	@Override
	public void destroy() {
		// TODO Auto-generated method stub

	}

	@Override
	public void doFilter(ServletRequest request, ServletResponse response,
			FilterChain chain) throws IOException, ServletException {
		//这个是HttpServletResponse,对服务器返回客户端的数据进行操作,这一块先注释掉,稍候讲HttpServletResponse的包装类HttpServletResponseWrappe
		//MyHttpServletResponseWrapper mHSResW = new MyHttpServletResponseWrapper((HttpServletResponse)response);//转换成代理类
		//创建包装类对象,我们改写完数据后需要把参数重新放到这个类里面,传递到controller里,有种狸猫换太子的感觉
		MyHttpServletRequestWrapper mHSReqW = new MyHttpServletRequestWrapper((HttpServletRequest)request);
		String parameter = request.getParameter("parameter");
		String value= Crypto.decrypt(parameter);//使用前面的AES解密方法进行解密
		mHSReqW.addParameter("parameter", value);//把键值对放进mHSReqW 里,便于传回controller
		chain.doFilter(mHSReqW, response);//这里是重点,一定要把mHSReqW而不是request放进来,这样contoller就能收到我们解密后的参数了:request.getParameter("parameter");
		//下面写对返回数据response进行拦截修改的部分,后面再讲
		//do Sth...
	}

	@Override
	public void init(FilterConfig arg0) throws ServletException {
		// TODO Auto-generated method stub

	}

}

           

还有配置web.xml,在过滤器位置添加上如下信息:

<filter>  
    <filter-name>cryptoFilter</filter-name>  
    <filter-class>你的拦截器类全限定名</filter-class>
  </filter> 
  <filter-mapping>
	<filter-name>cryptoFilter</filter-name>
	<url-pattern>你需要拦截的类型,我这里写的是:/*,意为全部请求都拦截</url-pattern>
  </filter-mapping>

           

一个简单的对加密数据先解密再放行的操作就完成了。接下来是如何对服务器返回客户端的数据进行统一加密

和HttpServletRequest有包装类HttpServletRequestWrapper一样,

HttpServletResponse也有包装类HttpServletResponseWrapper,便于我们获取返回的数据流中信息,一般情况来说,我们是无法获取返回参数的,它也没有对外提供获取参数的方法,当然就是为了不让我们瞎改,但是就是要改怎么办呢,继承HttpServletResponseWrapper:

import java.io.ByteArrayOutputStream;
import java.io.CharArrayWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;

public class MyHttpServletResponseWrapper extends HttpServletResponseWrapper {
	private ByteArrayOutputStream bytes = new ByteArrayOutputStream();
    private HttpServletResponse response;
    private PrintWriter pwrite;
	public MyHttpServletResponseWrapper(HttpServletResponse response) {
		super(response);
		this.response = response;
	}
    @Override
    public ServletOutputStream getOutputStream() throws IOException {
        return new MyServletOutputStream(bytes); // 将数据写到 byte 中
    }

    /**
     * 重写父类的 getWriter() 方法,将响应数据缓存在 PrintWriter 中
     */
    @Override
    public PrintWriter getWriter() throws IOException {
        try{
            pwrite = new PrintWriter(new OutputStreamWriter(bytes, "utf-8"));
        } catch(UnsupportedEncodingException e) {
            e.printStackTrace();
        }

        return pwrite;
    }

    /**
     * 获取缓存在 PrintWriter 中的响应数据 
     * @return
     */
    public byte[] getBytes() {
        if(null != pwrite) {
            pwrite.close();
            return bytes.toByteArray();
        } 

        if(null != bytes) {
            try {
                bytes.flush();
            } catch(IOException e) {
                e.printStackTrace();
            }
        }

        return bytes.toByteArray();
    }

    class MyServletOutputStream extends ServletOutputStream {
        private ByteArrayOutputStream ostream ;

        public MyServletOutputStream(ByteArrayOutputStream ostream) {
            this.ostream = ostream;
        }

        @Override
        public void write(int b) throws IOException {
            ostream.write(b); // 将数据写到 stream 中
        }

    }
}

           

继承完之后,我们的这个MyHttpServletResponseWrapper 就可以在过滤器中代替ServletResponse 进行工作了。

但是有一个细节,我们必须在用户的请求到来的时候把MyHttpServletResponseWrapper 放进去,不然我们在服务器返回客户端的时候是拿不到数据的,上一个完整的代码:

@Override
	public void doFilter(ServletRequest request, ServletResponse response,
			FilterChain chain) throws IOException, ServletException {
		//这里就是我们前面注释掉的MyHttpServletResponseWrapper包装类,需要在一开始就放进去传到controller,然后controller才能把参数放到这里面返回去,而不是放到ServletResponse中返回去,我们才能拿到数据。
		MyHttpServletResponseWrapper mHSResW = new MyHttpServletResponseWrapper((HttpServletResponse)response);//转换成代理类
		//这里做前面对request拦截并解密那些事儿......
		chain.doFilter(mHSReqW, mHSResW);//这里是最重要的一步,看到没,是把mHSResW放进去,不是前面把response放进去!!!!
		//controller已经把数据封装到缓冲区中,这里是取出来
		byte[] bytes = mHSResW.getBytes(); // 获取缓存的响应数据,看到没,是从我们前面放进去的mHSResW中取的
        System.out.println("返回数据大小:" + bytes.length);
		if(bytes.length>0){
			 String info = new BASE64Encoder().encode(bytes);//这里就是关于加密的另一个坑,我们需要在这里用base64进行编码转换成String,而不能直接用new String(bytes,“utf-8”)来获取,因为这么转换会丢失字节,导致加密完,到了客户端无法正常解密,同样,加密类那里需要用base64获取字节(换成被注释掉的那句),而不是以前的String.getBytes(),这是关于字节数组转字符串一定要注意的地方。
			 String cryptoStr = Crypto.encrypt(info).replaceAll("\r\n", "");//加密完太长的话会产生换行符,所以需要去掉
			 Map<String,Object> map=new HashMap<String, Object>();
			 map.put("responseBody", cryptoStr);
			 String responseBody = JSON.toJSONString(map);//重新包装成json字符串
			 HttpServletResponse hSResponse = (HttpServletResponse) response;//获取response,我们狸猫换太子终究是假的,返回数据最终还是需要真正的HttpServletResponse
			 hSResponse.reset();//很重要,清空buff,不然传回数据会丢失一部分
			 OutputStream op = hSResponse.getOutputStream();//获取输出流并写入我们改写的加密数据
			 op.write(responseBody.getBytes());
			 op.close();
		 }
	}
           

一个完整的、对数据进行提前解密、对数据进行事后加密的过滤器就结束了,写的比较匆忙,如果有不对的地方欢迎指正!