- 1 前言
- 2 我的實現方式與存在的問題
- 3 我想到的解決方案
- 4 其他相關代碼
1 前言
在做工程實踐項目的管理員模塊時,我想實現下面的效果:
- 1)在未登錄狀態下通過url訪問 /pages/admin/** 下的靜態頁面,除了 login.html,其他都會被攔截,然后跳轉到 login.html 頁面;
- 2)在 login.html 頁面登錄后,會自動跳轉到 /pages/admin/index.html 頁面;
先給個效果圖,對應的是:解決方案 3.3 放棄后端對/pages/admin/** 下靜態頁面的攔截,在前端做登錄檢測和跳轉
對錄屏軟件感興趣的請戳:Apowersoft 免費在線錄屏
2 我的實現方式與存在的問題
1)后端定義 JWTAdminInterceptor.java 來驗證登錄狀態,如果未登錄則重定向到 /pages/admin/login.html 頁面。代碼如下:

1 /** 2 * JWT驗證攔截器(管理員),對於需要身份認證的請求,必須先經過該攔截器處理 3 * @author southday 4 * @date 2019/2/26 5 */ 6 public class JWTAdminInterceptor extends HandlerInterceptorAdapter { 7 private static final Logger logger = LogManager.getLogger(JWTAdminInterceptor.class); 8 9 @Autowired 10 private AdminService adminService; 11 12 @Override 13 public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { 14 String jws = JWTer.getToken(); 15 JWTer jwter = new JWTer(jws); 16 boolean flag = false; 17 if (!jwter.isUsable()) { 18 logger.info("權限驗證失敗,異常:" + jwter.getException().getMessage() + " | [token = " + jws + "]"); 19 } else if (!CommonConst.USER_TYPE_ADMIN.equals(jwter.getUserType())) { 20 logger.info("權限驗證失敗,用戶類型不匹配,[token = " + jws + "]"); 21 } else { 22 flag = adminService.isAdminExists(jwter.getUserName()); 23 } 24 if (!flag) { 25 resp.setStatus(401); 26 resp.sendRedirect("/idevtools/pages/admin/login.html"); 27 } 28 return flag; 29 } 30 }

1 <!-- 攔截器配置 southday 2019.02.26 --> 2 <mvc:interceptors> 3 <!-- JWT 身份驗證攔截器,針對管理員需要先進行登陸后才能操作的請求進行攔截 --> 4 <mvc:interceptor> 5 <mvc:mapping path="/a/**"/> 6 <!-- 配置管理員模塊靜態頁面的攔截 southday 2019.05.17 --> 7 <mvc:mapping path="/pages/admin/**"/> 8 <mvc:exclude-mapping path="/a/login"/> 9 <mvc:exclude-mapping path="/a/adminInfo"/> 10 <mvc:exclude-mapping path="/pages/admin/login.html"/> 11 <bean class="cn.idevtools.interceptor.JWTAdminInterceptor"/> 12 </mvc:interceptor> 13 </mvc:interceptors>
3)前端在未登錄的情況下訪問:http://localhost:8080/idevtools/pages/admin/index.html,就會被攔截,然后重定向到管理員登錄頁面;
4)管理員 login.html 中加載了 admin.js 來實現登錄,登錄后要跳轉到 /pages/admin/index.html 頁面;如下:

1 /** 2 * 管理員登陸模態框 /pages/admin/login.html 3 * southday 2019.05.17 4 */ 5 let vmAdminLogin = new Vue({ 6 el: "#admin-login", 7 data: { 8 adminName: '', 9 password: '', 10 jcaptcha: '', 11 jcaptchaURL: cookurl('/idevtools/jcaptcha.jpg') 12 }, 13 methods: { 14 login: function() { 15 axios({ 16 method: 'post', 17 url: cookurl('/idevtools/a/login'), 18 params: { 19 adminName: vmAdminLogin.adminName, 20 password: vmAdminLogin.password, 21 jcaptcha: vmAdminLogin.jcaptcha 22 } 23 }).then(function(resp) { 24 let ret = resp.data 25 if (ret.code == 'VALID_ERROR') { 26 showValidMsgs(ret.data) 27 } else if (ret.code == 'FAILURE') { 28 toastr.error(ret.msg) 29 } else { 30 saveAdmin(ret.data) 31 saveAdminToken(resp.headers.token) 32 window.location.href = "/idevtools/pages/admin/index.html" 33 } 34 vmAdminLogin.changeJCaptcha() 35 }).catch(function(error) { 36 console.log(error) 37 vmAdminLogin.changeJCaptcha() 38 }) 39 }, 40 changeJCaptcha: function() { 41 vmAdminLogin.jcaptchaURL = changeVerifyCode() 42 }, 43 logout: function() { 44 axios({ 45 method: 'post', 46 url: cookurl('/idevtools/a/logout'), 47 headers: {'token': getAdminToken()} 48 }).then(function(resp) { 49 let ret = resp.data 50 if (ret.code == "SUCCESS") { 51 saveAdmin(null) 52 saveAdminToken(null) 53 } else { 54 toastr.error(ret.msg) 55 } 56 }).catch(function(error) { 57 console.log(error) 58 }) 59 } 60 } 61 })
存在的問題:
3 我想到的解決方案
3.1 前端跳轉時攜帶headers{'token': token} 不就行了(經驗證不可行)

(圖源:Location 對象:https://www.runoob.com/jsref/obj-location.html)
3.2 前端跳轉封裝請求,攜帶headers{'token': token},后端請求轉發 (經驗證不可行)
1)在前端封裝一個方法用來提交請求,參數為要跳轉的目標url,如下:

1 function redirect(url) { 2 axios({ 3 method: 'get', 4 url: cookurl(url), 5 headers: {'token': getAdminToken()} 6 }).then(function(resp) { 7 consolog.log('跳轉到' + url) 8 }).catch(function(error) { 9 console.log(error) 10 }) 11 }
需要注意的是:這里的url不是直接的靜態頁面形式,比如你要訪問 /pages/admin/index.html ,這里的url就可寫為:/idevtools/pages/admin/index;
- 因為前端請求時攜帶了headers {'token': token},而后端在做請求轉發時會共用前一次請求的request和response;
- 所以在攔截器中可以獲取到 token,進而正確跳轉;(經過驗證:請求確實轉發了,但前端頁面沒跳轉,在我看來,要在后端做靜態頁面的跳轉,還是需要重定向;當然如果你用的是jsp,確實可以用請求轉發來做頁面跳轉,因為jsp最終會編譯成Servlet)

1 @Controller 2 @RequestMapping("/pages/admin") 3 public class AdminHtmlController { 4 @GetMapping("/index") 5 public String adminIndex() throws Exception { 6 System.out.println("請求收到"); 7 return "forward:/pages/admin/index.html"; 8 } 9 }
3)前端 admin.js 中替換頁面跳轉的方法,將:window.location.href = "/idevtools/pages/admin/index.html" 改為 redirect('/idevtools/pages/admin/index')

所以,得出結論:要在后端做靜態頁面的跳轉,還是需要重定向;當然如果你用的是jsp,確實可以用請求轉發來做頁面跳轉,因為jsp最終會編譯成Servlet;
- 1)攔截 /pages/admin/** 下的所有靜態頁面,意味着管理員模塊的前端在進行頁面跳轉時都需要寫專門的請求,而不能直接寫靜態頁面的跳轉;
- 2)進而在 AdminHtmlController.java 中需要對專門的請求進行響應,這樣,前端 /pages/admin/** 下有多少個頁面需要跳轉,后端 AdminHtmlController 中就需要寫多少個 @GetMapping("/xxx") 來響應;這是一種很糟糕的設計;
如果某件事的解決方案很復雜,那就該反思是不是哪里出問題了,畢竟“簡潔是智慧的靈魂,冗長是膚淺的藻飾”
3.3 放棄后端對/pages/admin/** 下靜態頁面的攔截,在前端做登錄檢測和跳轉
注:后端 JWTAdminInterceptor 依舊會對管理員的操作進行攔截,如果管理員未登錄,則跳轉到登錄頁面;差別是不對 /pages/admin/** 下的靜態頁面進行攔截,普通用戶有可能在不登錄的情況下訪問到管理員模塊的相關頁面;
1)取消 spring-mvc.xml 中關於 /pages/admin/** 的攔截;

1 <!-- 攔截器配置 southday 2019.02.26 --> 2 <mvc:interceptors> 3 <!-- JWT 身份驗證攔截器,針對管理員需要先進行登陸后才能操作的請求進行攔截 --> 4 <mvc:interceptor> 5 <mvc:mapping path="/a/**"/> 6 <mvc:exclude-mapping path="/a/login"/> 7 <mvc:exclude-mapping path="/a/adminInfo"/> 8 <bean class="cn.idevtools.interceptor.JWTAdminInterceptor"/> 9 </mvc:interceptor> 10 </mvc:interceptors>
2)admin.js 中依舊用 window.location.href = "/idevtools/pages/admin/index.html" 來做頁面跳轉;
3)另外創建 admin-index.js,在 index.html 中引用該js文件,實現每次加載首頁時都會向后端請求管理員信息,如果未獲取到,說明管理員未登錄,則跳轉到登錄頁面;
admin-index.js 如下:

1 $(function() { 2 axios({ 3 method: 'get', 4 url: cookurl('/idevtools/a/adminInfo'), 5 headers: {'token': getAdminToken()} 6 }).then(function(resp) { 7 let ret = resp.data 8 if (ret.code == 'SUCCESS') { 9 saveAdmin(ret.data) 10 } else { 11 console.log(ret) 12 window.location.href = "/idevtools/pages/admin/login.html" 13 } 14 }).catch(function(error) { 15 console.log(error) 16 }) 17 })
index.html 如下:

1 <!DOCTYPE html> 2 <html lang="zh-CN"> 3 <head> 4 <meta charset="utf-8"> 5 <title>IDevTools</title> 6 </head> 7 <body> 8 welcome to admin index 9 10 <script src="../../js/jquery-3.3.1.min.js"></script> 11 <script src="../../js/bootstrap.min.js"></script> 12 <script src="../../js/axios.min.js"></script> 13 <script src="../../js/vue.min.js"></script> 14 <script src="../../js/toastr.min.js"></script> 15 <!-- my js --> 16 <script src="../../js/my/common.js"></script> 17 <script src="../../js/my/admin/admin-index.js"></script> 18 </body> 19 </html>
4)經過測試,可以實現我想要的效果:
現在主要來看看:普通用戶在什么情況下可以訪問到管理員模塊的相關頁面;
從方案3.3的實現中可以發現,進行登錄檢測和頁面跳轉控制是在前端 admin-index.js 中實現的,那么我們只要設置瀏覽器禁用js,就可以不執行這段js代碼,直接訪問到 index.html 頁面;
Firefox 中禁用 js 設置如下:
一起來看看是不是如我們預測的一樣:
經過驗證,發現普通用戶確實在某些情況下可以直接訪問到管理員頁面。上面只演示了瀏覽器禁用js的方式來繞過檢測,當然可能還存在其他方式;
- 1)普通用戶不會無緣無故去禁用js,所以大部分的擔心其實不必要的;
- 2)即使攻擊者繞過了前端的檢測進入了管理員的頁面,后端 JWTAdminInterceptor 依舊會對管理員的相關操作進行攔截,如果發現沒有登錄,同樣會跳轉到登錄頁面;
- 3)差別無非是攻擊者可以獲取到管理員模塊的靜態頁面代碼;
所以在進行Web開發時,重要的操作在后端都要做驗證,不能指望前端來做驗證,前端的校驗只是為了方便大多數用戶。不能圖一時方便,讓攻擊者有可乘之機。
4 其他相關代碼
common.js 部分代碼如下:

1 /** 通用 js 2 * @author southday 3 * @date 2019.02.27 4 * @version v0.1 5 */ 6 7 /** url更改器 southday 2019.03.01 8 * 1) 前端單獨開發,測試時,url前面需要加http://localhost:8080 9 * 2) 集成到java web項目中時,url前面不用加http://localhost:8080 10 * 該方法是為了方便以上兩種情況的相互轉換,真正部署時,要取消該方法的調用 11 */ 12 function cookurl(url) { 13 // return url; 14 return 'http://localhost:8080' + url; 15 } 16 17 /** 更換驗證碼 */ 18 function changeVerifyCode() { 19 return cookurl('/idevtools/jcaptcha.jpg?r=' + (Math.random())) 20 } 21 22 /** code = VALID_ERROR,表單驗證失敗,提示消息 23 * southday 2019.03.01 24 */ 25 function showValidMsgs(validMsgs) { 26 for (i = 0, len = validMsgs.length; i < len; i++) 27 toastr.warning(validMsgs[i].errorMsg) 28 } 29 30 /** 31 * 從localStorage中獲取adminToken 32 * southday 2019.05.17 33 * @returns {string} 34 */ 35 function getAdminToken() { 36 return localStorage.getItem("adminToken") 37 } 38 39 /** 40 * 將adminToken保存到localStorage中 41 * @param token 42 */ 43 function saveAdminToken(token) { 44 localStorage.setItem("adminToken", token) 45 } 46 47 /** 48 * 將admin保存到localStorage 49 * southday 2019.05.17 50 * @param admin 51 */ 52 function saveAdmin(admin) { 53 localStorage.setItem("admin", ($.isEmptyObject(admin) ? null : JSON.stringify(admin))) 54 } 55 56 /** 57 * 從localStorage中取user 58 * southday 2019.05.17 59 * @returns {admin} 60 */ 61 function getAdmin() { 62 let a = localStorage.getItem("admin") 63 return $.isEmptyObject(a) ? null : JSON.parse(a) 64 }
轉載請說明出處,have a good time! :D