由于之前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的一緻,基本上可以判定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();
}
}
一個完整的、對資料進行提前解密、對資料進行事後加密的過濾器就結束了,寫的比較匆忙,如果有不對的地方歡迎指正!