天天看點

實作伺服器(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();
		 }
	}
           

一個完整的、對資料進行提前解密、對資料進行事後加密的過濾器就結束了,寫的比較匆忙,如果有不對的地方歡迎指正!