1. 加密解密
1.1 前端js加密概述
對系統安全性要求比較高,那么需要選擇https協議來傳輸數據。當然很多情況下一般的web網站,如果安全要求不是很高的話,用http協議就可以了。在這種情況下,密碼的明文傳輸顯然是不合適的,因為如果請求在傳輸過程中被截了,就可以直接拿明文密碼登錄網站了。
HTTPS(443)在HTTP(80)的基礎上加入了SSL(Secure Sockets Layer 安全套接層)協議,SSL依靠證書來驗證服務器的身份,並為瀏覽器和服務器之間的通信加密。傳輸前用公鑰加密,服務器端用私鑰解密。
對於使用http協議的web前端的加密,只能防君子不能防小人。前端是完全暴露的,包括你的加密算法。
知道了加密算法,密碼都是可以破解的,只是時間問題。請看知乎上的一篇文章:對抗拖庫
所以加密是為了增加破解的時間成本,如果破解需要花費的時間讓人難以接受,這也就達到了目的。
而為了保證數據庫中存儲的密碼更安全,則需要在后端用多種單向(非對稱)加密手段混合進行加密存儲。
前端加密后端又需要解密,所以需要對稱加密算法,即前端使用 encrypted = encrypt(password+key),后端使用 password = decrypt(encrypted +key) ,前端只傳輸密碼與key加密后的字符串encrypted ,這樣即使請求被攔截了,也知道了加密算法,但是由於缺少key所以很難破解出明文密碼。所以這個key很關鍵。而這個key是由后端控制生成與銷毀的,用完即失效,所以即使可以模擬用加密后的密碼來發請求模擬登錄,但是key已經失效了,后端還是驗證不過的。
注意,如果本地環境本就是不安全的,key被知道了,那就瞬間就可以用解密算法破解出密碼了。這里只是假設傳輸的過程中被截獲的情形。所以前端加密是防不了小人的。如果真要防,可以將加密算法的js文件進行壓縮加密,不斷更新的手段來使js文件難以獲取,讓黑客難以獲取加密算法。變態的google就是這么干的,自己實現一個js虛擬機,通過不斷更新加密混淆js文件讓加密算法難以獲取。這樣黑客不知道加密算法就無法破解了。
常用的對稱加密算法有DES、3DES(TripleDES)、AES、RC2、RC4、RC5和Blowfis。可以參考:常用加密算法的Java實現總結
這里采用js端與java端互通的AES加密算法。
1.2 前后端加密解密
1.2.1 引用的js加密庫
<script src="${request.contextPath}/resources/plugins/jQuery/jquery-2.2.4.min.js"></script> <script src="${request.contextPath}/resources/cryptojs/aes.js"></script> <script src="${request.contextPath}/resources/cryptojs/mode-ecb-min.js"></script>
- 1
- 2
- 3
1.2.2 js加密解密
var data = "888888"; var srcs = CryptoJS.enc.Utf8.parse(data); var key = CryptoJS.enc.Utf8.parse('o7H8uIM2O5qv65l2');//Latin1 w8m31+Yy/Nw6thPsMpO5fg== function Encrypt(word){ var srcs = CryptoJS.enc.Utf8.parse(word); var encrypted = CryptoJS.AES.encrypt(srcs, key, {mode:CryptoJS.mode.ECB,padding: CryptoJS.pad.Pkcs7}); return encrypted.toString(); } function Decrypt(word){ var decrypt = CryptoJS.AES.decrypt(word, key, {mode:CryptoJS.mode.ECB,padding: CryptoJS.pad.Pkcs7}); return CryptoJS.enc.Utf8.stringify(decrypt).toString(); }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
這里key是頁面加載的時候由服務器端生成的,用隱藏域保存。
1.2.3 Java端加密解密(PKCS5Padding與js的Pkcs7一致)
package com.jykj.demo.util; import javax.crypto.Cipher; import javax.crypto.KeyGenerator; import javax.crypto.spec.SecretKeySpec; import org.apache.commons.codec.binary.Base64; import sun.misc.BASE64Decoder; public class EncryptUtil { private static final String KEY = "abcdefgabcdefg12"; private static final String ALGORITHMSTR = "AES/ECB/PKCS5Padding"; public static String base64Encode(byte[] bytes){ return Base64.encodeBase64String(bytes); } public static byte[] base64Decode(String base64Code) throws Exception{ return new BASE64Decoder().decodeBuffer(base64Code); } public static byte[] aesEncryptToBytes(String content, String encryptKey) throws Exception { KeyGenerator kgen = KeyGenerator.getInstance("AES"); kgen.init(128); Cipher cipher = Cipher.getInstance(ALGORITHMSTR); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(encryptKey.getBytes(), "AES")); return cipher.doFinal(content.getBytes("utf-8")); } public static String aesEncrypt(String content, String encryptKey) throws Exception { return base64Encode(aesEncryptToBytes(content, encryptKey)); } public static String aesDecryptByBytes(byte[] encryptBytes, String decryptKey) throws Exception { KeyGenerator kgen = KeyGenerator.getInstance("AES"); kgen.init(128); Cipher cipher = Cipher.getInstance(ALGORITHMSTR); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(decryptKey.getBytes(), "AES")); byte[] decryptBytes = cipher.doFinal(encryptBytes); return new String(decryptBytes); } public static String aesDecrypt(String encryptStr, String decryptKey) throws Exception { return aesDecryptByBytes(base64Decode(encryptStr), decryptKey); } /** * 測試 * */ public static void main(String[] args) throws Exception { String content = "Test String么么噠"; //0gqIDaFNAAmwvv3tKsFOFf9P9m/6MWlmtB8SspgxqpWKYnELb/lXkyXm7P4sMf3e System.out.println("加密前:" + content); System.out.println("加密密鑰和解密密鑰:" + KEY); String encrypt = aesEncrypt(content, KEY); System.out.println(encrypt.length()+":加密后:" + encrypt); String decrypt = aesDecrypt(encrypt, KEY); System.out.println("解密后:" + decrypt); } }
2. 驗證碼
2.1 概述
驗證碼是用來區分人機的操作。
驗證碼划代的標准是人機識別過程中基於對人類知識的應用。
第一代:標准驗證碼
這一代驗證碼是即是我們常見的圖形驗證碼、語音驗證碼,基於機器難以處理復雜的計算機視覺及語音識別問題,而人類卻可以輕松的識別來區分人類及機器。這一代驗證碼初步利用了人類知識容易解答,而計算機難以解答的機制進行人機判斷。
第二代:創新驗證碼
第二代驗證碼是基於第一代驗證碼的核心思想(通過人類知識可以解答,而計算機難以解答的問題進行人機判斷)而產生的創新的交互優化型驗證碼。第二代驗證碼基於第一代驗證碼的核心原理--“人機之間知識的差異”,拓展出大量創新型驗證碼。
第三代:無知識型驗證碼
第三代驗證碼最大的特點是不再基於知識進行人機判斷,而是基於人類固有的生物特征以及操作的環境信息綜合決策,來判斷是人類還是機器。無知識型驗證碼最大特點即無需人類思考,從而不會打斷用戶操作,進而提供更好的用戶體驗。
如Google的新版ReCaptcha、阿里巴巴的滑動驗證。參考知乎 關於驗證碼
2.2 驗證碼生成器
package com.jykj.demo.util; import java.awt.Color; import java.awt.Font; import java.awt.Graphics2D; import java.awt.image.BufferedImage; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.Random; import javax.imageio.ImageIO; /** * 驗證碼生成器 * */ public class ValidateCode { // 圖片的寬度。 private int width = 160; // 圖片的高度。 private int height = 28; // 驗證碼字符個數 private int codeCount = 4; // 驗證碼干擾線數 private int lineCount = 150; // 驗證碼 private String code = null; // 驗證碼圖片Buffer private BufferedImage buffImg = null; private char[] codeSequence = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '1', '2', '3', '4', '5', '6', '7', '8', '9' }; public ValidateCode() { this.createCode(); } /** * * @param width * 圖片寬 * @param height * 圖片高 */ public ValidateCode(int width, int height) { this.width = width; this.height = height; this.createCode(); } /** * * @param width * 圖片寬 * @param height * 圖片高 * @param codeCount * 字符個數 * @param lineCount * 干擾線條數 */ public ValidateCode(int width, int height, int codeCount, int lineCount) { this.width = width; this.height = height; this.codeCount = codeCount; this.lineCount = lineCount; this.createCode(); } public void createCode() { int x = 0, fontHeight = 0, codeY = 0; int red = 0, green = 0, blue = 0; x = width / (codeCount + 2);// 每個字符的寬度 fontHeight = height - 2;// 字體的高度 codeY = height - 4; // 圖像buffer buffImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g = buffImg.createGraphics(); // 生成隨機數 Random random = new Random(); // 將圖像填充為白色 g.setColor(Color.WHITE); g.fillRect(0, 0, width, height); // 創建字體 Font font = new Font("Fixedsys", Font.BOLD, fontHeight); g.setFont(font); //干擾線 for (int i = 0; i < lineCount; i++) { int xs = random.nextInt(width); int ys = random.nextInt(height); int xe = xs + random.nextInt(width / 8); int ye = ys + random.nextInt(height / 8); red = random.nextInt(255); green = random.nextInt(255); blue = random.nextInt(255); g.setColor(new Color(red, green, blue)); g.drawLine(xs, ys, xe, ye); } // randomCode記錄隨機產生的驗證碼 StringBuffer randomCode = new StringBuffer(); // 隨機產生codeCount個字符的驗證碼。 for (int i = 0; i < codeCount; i++) { String strRand = String.valueOf(codeSequence[random.nextInt(codeSequence.length)]); // 產生隨機的顏色值,讓輸出的每個字符的顏色值都將不同。 red = random.nextInt(255); green = random.nextInt(255); blue = random.nextInt(255); g.setColor(new Color(red, green, blue)); g.drawString(strRand, (i + 1) * x, codeY); // 將產生的四個隨機數組合在一起。 randomCode.append(strRand); } // 將四位數字的驗證碼保存到Session中。 code = randomCode.toString(); } public void write(String path) throws IOException { OutputStream sos = new FileOutputStream(path); this.write(sos); } public void write(OutputStream sos) throws IOException { ImageIO.write(buffImg, "png", sos); sos.close(); } public BufferedImage getBuffImg() { return buffImg; } public String getCode() { return code; } }
2.3 控制器使用驗證碼 如 CodeController
@RequestMapping("/getCode.do") public void getCode(HttpServletRequest reqeust, HttpServletResponse response) throws IOException { response.setContentType("image/jpeg"); // 禁止圖像緩存。 response.setHeader("Pragma", "no-cache"); response.setHeader("Cache-Control", "no-cache"); response.setDateHeader("Expires", 0); HttpSession session = reqeust.getSession(); ValidateCode vCode = new ValidateCode(100, 28, 4, 100); session.setAttribute(Helper.SESSION_CHECKCODE, vCode.getCode()); vCode.write(response.getOutputStream()); }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
3. 應用
實現功能:前端AES加密傳輸后端解密以及n次輸入驗證不通過后需要驗證碼
有了上面的基礎,實現起來應該不難了。
3.1 login.html
<input type="hidden" id="KEY" value="${Session.login_token}"/> <form action="signIn" method="post" id="loginForm"> <div class="form-group has-feedback"> <input type="text" class="form-control" placeholder="Email" name="username" onkeydown="javascript:if(event.keyCode==13) $('#password').focus();"> </div> <div class="form-group has-feedback"> <input type="password" class="form-control" id="password" id="formPwd" onkeydown="javascript:if(event.keyCode==13) login();" > </div> <#if Session.login_failure_count?? && (Session.login_failure_count <=0) > <div class="form-group has-feedback"> <input name="checkCode" onkeydown="javascript:if(event.keyCode==13) login();" type="text" id="checkCode" maxlength="4" onblur="checkTheCode(this)" style="width:120px;"/> <img src="getCode.do" id="CreateCheckCode" align="middle" title="點擊刷新驗證碼" onclick="getCode()" style="cursor: pointer;"> <span id="checkCodeSpan" style="float: right;color: red;"></span> </div> </#if> <div class="row"> <div class="col-xs-8"> <div class="checkbox icheck"> <label> <input type="checkbox" name="remember" checked="checked" >記住密碼 </label> </div> </div> <!-- /.col --> <div class="col-xs-4"> <button type="button" onclick="login()" class="btn btn-primary btn-block btn-flat">登錄</button> </div> <!-- /.col --> </div> </form> <script> function login(){ $('#loginForm').form('submit',{ onSubmit: function(param){ var username = $('#loginForm input[name=username]').val(); if($.trim(username)==''){ alert('賬號不能為空!') $('#loginForm input[name=username]').focus(); return false; } var p = $('#loginForm #password').val(); if($.trim(p)==''){ alert('密碼不能為空!') $('#loginForm #password').focus(); return false; } var checkCodeInput = $('#loginForm #checkCode'); if(checkCodeInput.length>0){//判斷元素是否存在 var checkCode = checkCodeInput.val(); if($.trim(checkCode)=='' || checkCode.length!=4 ){ alert('請輸入4位驗證碼!') checkCodeInput.select(); checkCodeInput.focus(); return false; } } var key = $('#KEY').val(); key = CryptoJS.enc.Utf8.parse(key); p = CryptoJS.enc.Utf8.parse($.trim(p)); var encrypted = CryptoJS.AES.encrypt(p, key, {mode:CryptoJS.mode.ECB,padding: CryptoJS.pad.Pkcs7}); param.password = encrypted.toString(); }, success:function(data){ var data = eval('(' + data + ')'); if (data.success){ window.location.href = '${request.contextPath}/'; }else{ if(data.info=='timeout'){//可能已經就登錄了,無需再次登錄 alert('登錄超時或已經登錄!'); window.location.href = '${request.contextPath}/'; }else if('checkCode'==data.info){//需要驗證碼了 alert('用戶名或密碼錯誤!'); window.location.href = 'login'; }else if('codeError'==data.info){//驗證碼錯誤 getCode(); $('#checkCodeSpan').text('驗證碼錯誤'); $('#loginForm #checkCode').select(); $('#loginForm #checkCode').focus(); }else{ //登錄失敗,更新login_token $('#KEY').val(data.data); if($('#checkCodeSpan')){ $('#checkCodeSpan').text(''); } alert(data.info); } } } }) ; } function getCode(){ $("#CreateCheckCode").attr('src',"getCode.do?nocache=" + new Date().getTime()); } </script>
3.2 Controller
在請求登錄頁面時需要后端生成一個隨機的16位字符串的key,用於前后端加密解密用,該key在登錄成功后銷毀,存儲在session中。
@RequestMapping(value = "/login", method = RequestMethod.GET) public String login(){ //生成login_token session.setAttribute(Helper.SESSION_LOGIN_TOKEN,RandomUtil.generateString(16));//登錄令牌,用於密碼加密的key,16位長度 if(session.getAttribute(Helper.SESSION_USER) == null){ return "login"; } else return "redirect:/"; }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
接下來是提交form表單的請求
@RequestMapping(value = "/signIn", method = RequestMethod.POST,produces = "text/html;charset=UTF-8") @ResponseBody public String signIn(String username,String password,boolean remember,String checkCode) throws AuthorizationException{ System.out.println(username+","+password+","+remember+","+checkCode); Object token = session.getAttribute(Helper.SESSION_LOGIN_TOKEN);//原始令牌 if(token==null) return JSON.toJSONString(new Result(false,"timeout"));//登錄成功后token失效,則頁面失效,客戶端需要重定向到主界面 Object countObj = session.getAttribute(Helper.SESSION_LOGIN_FAILURE_COUNT); int count = countObj==null?ConfigInfo.login_failure_count:Integer.parseInt(countObj.toString()); System.out.println("剩余次數:"+count); //驗證碼邏輯 if(count<=0){//需要驗證碼 Object oldCode = session.getAttribute(Helper.SESSION_CHECKCODE); if(checkCode==null||oldCode==null){//該登錄界面沒有驗證碼字段,但是已經消耗掉了剩余次數,說明該頁面是過期頁面,需要重新登錄 return JSON.toJSONString(new Result(false,"timeout"));//客戶端需要重定向到主界面 } if(checkCode.trim().isEmpty()) return JSON.toJSONString(new Result(false,"請輸入驗證碼")); if(oldCode.toString().equalsIgnoreCase(checkCode)){ //驗證通過,可信客戶端,給兩次剩余次數 count=2; session.setAttribute(Helper.SESSION_LOGIN_FAILURE_COUNT,2); }else{ return JSON.toJSONString(new Result(false,"codeError"));//驗證碼不正確,客戶端需要刷新驗證碼 } } //解密 try { password = EncryptUtil.aesDecrypt(password,token.toString());//解密后 System.out.println("Decrypt:"+password); } catch (Exception e) { e.printStackTrace(); return JSON.toJSONString(new Result(false,"timeout"));//客戶端需要重定向到主界面 } //登錄校驗 String key = RandomUtil.generateString(16);//重新生成登錄令牌,任何登錄失敗的操作都需要更新登錄令牌 ViewSysUser user = sysUserService.selectUserPwd(username,password); if(user == null){ session.setAttribute(Helper.SESSION_LOGIN_TOKEN,key); session.setAttribute(Helper.SESSION_LOGIN_FAILURE_COUNT,--count);//剩余次數-1 if(count<=0) return JSON.toJSONString(new Result(false,"checkCode"));//客戶端需要重定向到登錄界面將驗證碼顯示出來 return JSON.toJSONString(new Result(false,"用戶名或密碼錯誤!",key)); }else{ if(user.getUserid()!=ConfigInfo.admin_id && !user.getuStatus().equals(ConfigInfo.user_status_normal)) { session.setAttribute(Helper.SESSION_LOGIN_TOKEN,key); return JSON.toJSONString(new Result(false,"登錄失敗,該賬號已被禁止使用!",key)); } //登錄成功 session.removeAttribute(Helper.SESSION_LOGIN_TOKEN); loginUser = user; session.setAttribute(Helper.SESSION_USER,loginUser); sysEventService.insertEventLog(Helper.logTypeSecurity,username+" 登錄系統"); return JSON.toJSONString(new Result(true,"登錄成功!")); } }
下面是生成隨機數的工具類,很簡單
package com.jykj.demo.util; import java.util.Random; public class RandomUtil { public static final String ALLCHAR = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; public static final String LETTERCHAR = "abcdefghijkllmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; public static final String NUMBERCHAR = "0123456789"; /** * 返回一個定長的隨機字符串(只包含大小寫字母、數字) * * @param length * 隨機字符串長度 * @return 隨機字符串 */ public static String generateString(int length) { StringBuffer sb = new StringBuffer(); Random random = new Random(); for (int i = 0; i < length; i++) { sb.append(ALLCHAR.charAt(random.nextInt(ALLCHAR.length()))); } return sb.toString(); } /** * 返回一個定長的隨機純字母字符串(只包含大小寫字母) * * @param length * 隨機字符串長度 * @return 隨機字符串 */ public static String generateMixString(int length) { StringBuffer sb = new StringBuffer(); Random random = new Random(); for (int i = 0; i < length; i++) { sb.append(LETTERCHAR.charAt(random.nextInt(LETTERCHAR.length()))); } return sb.toString(); } /** * 返回一個定長的隨機純大寫字母字符串(只包含大小寫字母) * * @param length * 隨機字符串長度 * @return 隨機字符串 */ public static String generateLowerString(int length) { return generateMixString(length).toLowerCase(); } /** * 返回一個定長的隨機純小寫字母字符串(只包含大小寫字母) * * @param length * 隨機字符串長度 * @return 隨機字符串 */ public static String generateUpperString(int length) { return generateMixString(length).toUpperCase(); } /** * 生成一個定長的純0字符串 * * @param length * 字符串長度 * @return 純0字符串 */ public static String generateZeroString(int length) { StringBuffer sb = new StringBuffer(); for (int i = 0; i < length; i++) { sb.append('0'); } return sb.toString(); } /** * 根據數字生成一個定長的字符串,長度不夠前面補0 * * @param num * 數字 * @param fixdlenth * 字符串長度 * @return 定長的字符串 */ public static String toFixdLengthString(long num, int fixdlenth) { StringBuffer sb = new StringBuffer(); String strNum = String.valueOf(num); if (fixdlenth - strNum.length() >= 0) { sb.append(generateZeroString(fixdlenth - strNum.length())); } else { throw new RuntimeException("將數字" + num + "轉化為長度為" + fixdlenth + "的字符串發生異常!"); } sb.append(strNum); return sb.toString(); } /** * 每次生成的len位數都不相同 * * @param param * @return 定長的數字 */ public static int getNotSimple(int[] param, int len) { Random rand = new Random(); for (int i = param.length; i > 1; i--) { int index = rand.nextInt(i); int tmp = param[index]; param[index] = param[i - 1]; param[i - 1] = tmp; } int result = 0; for (int i = 0; i < len; i++) { result = result * 10 + param[i]; } return result; } }
3.4 實現思路
現在淘寶登錄界面采用的是 無知識型驗證碼,只需要拖動滑塊來判斷是否是機器還是人,如果拖滑塊驗證失敗,會彈出驗證碼輸入或點擊選擇的框,來進行二次驗證。若驗證成功后,連續5次輸入錯誤的用戶名或密碼,則又會彈出驗證碼來需要繼續驗證。也就是說有一個風險分析系統,如果滿足一定的條件(如連續多次輸入錯誤等)則需要加強驗證。風險分析系統要綜合多種因素如ip,用戶信息等等。Google更簡單,通過點擊復選框(I’m not a robot)就通過驗證。
剛開始時是不需要驗證碼的,用session來存儲剩余次數,當連續5次驗證都失敗后,該計數遞減為0,則后台判斷該客戶端不是很可信,需要驗證碼來加強驗證,重新刷新登錄界面(可以重定向實現)把驗證碼輸入框加載出來。客戶端需要同時提交賬號密碼以及驗證碼到后台驗證,若驗證碼通過驗證,重新將次數復位(或自定義設置),表示該客戶端暫時可信下次提交登錄時可以不需要驗證碼。
對於密碼的加密傳輸與后端解密,key的生成與銷毀的控制很關鍵。當加載登錄頁面時,由后台生成一個key給該頁面,並保存到隱藏域中,同時該key也是存在session中。前端js用AES加密算法將密碼和key混合加密,提交給后台,后台用相應的解密算法還原出原始密碼,然后該原始密碼用存儲時使用的多重混合加密算法進行加密與數據庫中的密碼匹配驗證。當驗證成功后,移除session中的key。而驗證失敗后的邏輯很關鍵,驗證失敗后,需要重新生成一個key給客戶端,所以客戶端通過返回的信息將key賦值到那個隱藏域字段中,這樣達到刷新key的目的。
總體來說,驗證碼的邏輯會有點復雜。驗證碼的驗證最好放到后台來驗證,如果放到前台就需要用一個隱藏域字段來存這個驗證碼,這樣的話機器也可以獲取到,那機器就不用識別圖片就可以驗證了,這樣驗證碼就失去了作用。