由前端登录验证,页面跳转,携带headers token引发的思考和尝试

目录

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 }
View Code
2)spring-mvc.xml 拦截器配置如下:拦截器中配置了对 /pages/admin/** 下的所有静态页面进行拦截,除了 /pages/admin/login.html 页面;
 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>
View Code

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 })
View Code

存在的问题:

问题就在于(4)中管理员登录后的跳转语句:window.location.href = "/idevtools/pages/admin/index.html",其没有携带headers: {'token': getAdminToken()},所以在跳转时会被后端拦截,然后又重定向到 login.html,就这样一直循环;

3 我想到的解决方案

3.1 前端跳转时携带headers{'token': token} 不就行了(经验证不可行)

很遗憾,我找了好多资料,目前发现并不能实现这样的效果;一般用js做前端跳转,代码为:window.location.href = "/idevtools/pages/admin/index.html",查阅资料后得知 window.location 中并不支持 headers 的设置,如下:

(图源: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 }
View Code

需要注意的是:这里的url不是直接的静态页面形式,比如你要访问 /pages/admin/index.html ,这里的url就可写为:/idevtools/pages/admin/index;

2)后端设置相应的 AdminHtmlController.java 来处理这个请求;
  • 因为前端请求时携带了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 }
View Code
关于SpringMVC视图解析器的请求转发和重定向,可以参考:SpringMVC系列(九)自定义视图、重定向、转发

3)前端 admin.js 中替换页面跳转的方法,将:window.location.href = "/idevtools/pages/admin/index.html" 改为 redirect('/idevtools/pages/admin/index')

4)在测试时出现了:StackOverflowError,原因我之前的博客已经分析过了:SSM 返回静态页面HTML Controller 被递归调用引起的StackOverflowError
 
5)修改代码,将请求地址改为:redirect('/idevtools/pages/admin/aindex'),AdminHtmlController.java 中的 adminIndex的@GetMapping改为@GetMapping("/aindex"),继续测试,发现没有递归调用,但是前端也没有跳转到 index.html 页面;

所以,得出结论:要在后端做静态页面的跳转,还是需要重定向;当然如果你用的是jsp,确实可以用请求转发来做页面跳转,因为jsp最终会编译成Servlet;

此外,即使上述的操作都可行,方案3.2 也不是一个好的设计。因为:
  • 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>
View Code

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 })
View Code

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>
View Code

4)经过测试,可以实现我想要的效果:

现在主要来看看:普通用户在什么情况下可以访问到管理员模块的相关页面

从方案3.3的实现中可以发现,进行登录检测和页面跳转控制是在前端 admin-index.js 中实现的,那么我们只要设置浏览器禁用js,就可以不执行这段js代码,直接访问到 index.html 页面;

Firefox 中禁用 js 设置如下:

一起来看看是不是如我们预测的一样:

经过验证,发现普通用户确实在某些情况下可以直接访问到管理员页面。上面只演示了浏览器禁用js的方式来绕过检测,当然可能还存在其他方式;

其实我觉得这种情况是不应该出现的,所以我才会想拦截 /pages/admin/** 下所有静态页面的访问,但我一时间没找到好的解决方法,就只想到了方案3.3这种不太完美的方法。不过好在:
  • 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 }
View Code

转载请说明出处,have a good time! :D

posted @ 2019-05-18 16:55  southday  阅读(12952)  评论(4编辑  收藏