由于之前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},然后结果为:
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsIyZuBnLzczMxMTNxEjM0IjMwAjMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
加密和解密内容和java的一致,基本上可以判定java加密的数据到了js这里就可以正常解密,反过来也是一样。
加密的事情基本上到这里就结束了,主要在于文章开始提到的,模式用cbc模式,补齐方式用ZeroPadding或noPadding,内容统一utf-8,
三、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();
}
}
一个完整的、对数据进行提前解密、对数据进行事后加密的过滤器就结束了,写的比较匆忙,如果有不对的地方欢迎指正!