重要链接:
「系列文章目录」
「项目源码(GitHub)」
之前我老说自己写文章不容易,一篇有时候要搞七八个小时,想到大多数人恐怕没我这么用心,就偷懒偷得比较心安理得。但最近刷 B 站,发现一些 UP 主居然会花上百个小时去剪一个三分钟的视频,我了个乖乖,虽说写文章不像他们发视频那样能挣钱,但是我还是被他们这种为梦想爆肝的精神感动了,活该人家成功(tū tóu)啊。不过感动归感动,对于我来说写文章是一种兴趣,要是做到这种程度恐怕就没什么幸福感了。如果每篇文章都是这样的篇幅,两周一更其实压力也并不小,毕竟除去开头结尾吹牛扯皮和代码,一篇教程最起码有三千字是要字句斟酌的。
本篇文章主要内容如下:
有的同学可能已经发现了,我们的系统一直没有登出功能。其实按照过去的登录验证方法,服务器端并不会记住 “登录成功” 的状态,也就是说,是否执行这个方法对服务器来说并没有什么区别,用户完全可以不用登录自行构造请求访问后端的各种资源。仅仅依靠前端的一些判断,只能骗骗不懂计算机的小朋友们。
之前的文章 「Vue + Spring Boot 项目实战(六):前端路由与登录拦截器」 中分别讲解了前端拦截和后端拦截,在我们这个前后端分离的破项目中,必须把这两种方式结合起来,才能实现真正意义上的访问控制。(这也体现了前后端分离令人蛋疼之处)
在我们引入 Shiro 作为安全框架之后,拥有了对登录状态进行管理的能力,这时,我们才能实现真正意义上的登入和登出。登入上节已经讲过了,这里我们简单实现一下登出。
后端代码如下:
@ResponseBody
@GetMapping("api/logout")
public Result logout() {
Subject subject = SecurityUtils.getSubject();
subject.logout();
String message = "成功登出";
return ResultFactory.buildSuccessResult(message);
}
核心就是 subject.logout()
,默认 Subject 接口是由 DelegatingSubject 类实现,其 logout
方法如下:
public void logout() {
try {
this.clearRunAsIdentitiesInternal();
this.securityManager.logout(this);
} finally {
this.session = null;
this.principals = null;
this.authenticated = false;
}
}
可以看出,该方法会清除 session、principals,并把 authenticated 设置为 false。有兴趣的同学可以再看看 SecurityManager 中做了什么,这里就不赘述了。
之前我们在后端配置了拦截器,由于登出功能不需要被拦截,所以我们还需要修改配置类 MyWebConfigurer
的 addInterceptors()
方法,添加一条路径:
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(getLoginIntercepter())
.addPathPatterns("/**")
.excludePathPatterns("/index.html")
.excludePathPatterns("/api/login")
.excludePathPatterns("/api/logout");
}
前端要做的事情有两件,一是显示,二是逻辑。原来我们一直讲前端简单后端复杂,但现在前端能做的事情太多了,谁复杂还真不好说。而且过去十几年各种后端语言此起彼伏,但 JavaScript 一枝独秀稳坐泰山,所以学它肯定不亏。虽然我们的教程讲 Vue,但是我还是想提醒一下各位兴趣在前端的小伙伴,不要只会用框架,框架一浪拍一浪死的很快,原生 JS 才是你的立身之本。
显示这里我简单写一下,在原来顶部的导航栏里加一个登出按钮,如图:
写在
标签里即可:
<i class="el-icon-switch-button" v-on:click="logout" style="float:right;font-size: 40px;color: #222;padding: 10px">i>
调整样式:
.el-icon-switch-button {
cursor: pointer;
outline:0;
}
在 methods 中编写 logout()
方法:
logout () {
var _this = this
this.$axios.get('/logout').then(resp => {
if (resp.data.code === 200) {
// 前后端状态保持一致
_this.$store.commit('logout')
_this.$router.replace('/login')
}
})
}
在 store 中定义 logout 方法:
logout (state) {
state.user = []
window.localStorage.removeItem('user')
}
这样,登出功能就开发完成了。
现在,虽然我们登录登出的状态没问题了,但是还有关键的一步没有做。上篇文章的最后,我们说可以通过在控制台输入类似
window.localStorage.setItem('user', JSON.stringify({"name":"哈哈哈"}));
的命令来绕过前端的 “全局前置守卫”(router.beforeEach),所以要想真正实现登录拦截,必须在后端也判断用户是否登录以及登录的是哪个瓜皮用户,而这就需要前端向后端发送用户信息。
说到这里,我感到头皮掠过一丝凉意,因为关于这个用户信息如何表示、如何存储、如何验证是一个大问题,讲明白不容易。你也许能在网上搜到许多讲这个的文章,比我写的好的有很多,但我发现瞎说的更多,所以我希望能给读者们讲清楚,不要被其它文章带跑偏了。
先说最简单的认证方法,即前端在每次请求时都加上用户名和密码,交由后端验证。这种方法的弊端有两个:
为了在某种程度上解决上述两个问题,有两种改进的方案 —— session 与 token。
许多语言在网络编程模块都会实现会话机制,即 session。利用 session,我们可以管理用户状态,比如控制会话存在时间,在会话中保存属性等。其作用方式通常如下:
也就是说,客户端只需要在登录的时候发送一次用户名密码,此后只需要在发送请求时带上 sessionId,服务器就可以验证用户是否登录了。
session 存储在内存中,在用户量较少时访问效率较高,但如果一个服务器保存了几十几百万个 session 就十分难顶了。同时由于同一用户的多次请求需要访问到同一服务器,不能简单做集群,需要通过一些策略(session sticky)来扩展,比较麻烦。
之前见过有的人把 sessionId 持久化到数据库里,只存个 id,大头还在内存里,这个操作我是看不懂的。。。
虽然 session 能够比较全面地管理用户状态,但这种方式毕竟占用了较多服务器资源,所以有人想出了一种无需在服务器端保存用户状态(称为 “无状态”)的方案,即使用 token(令牌)来做验证。
对于 token 的理解,比较常见的误区是:
简单来说,一个真正的 token 本身是携带了一些信息的,比如用户 id、过期时间等,这些信息通过签名算法防止伪造,也可以使用加密算法进一步提高安全性,但一般没有人会在 token 里存储密码,所以不加密也无所谓,反正被截获了结果都一样。(一般会用 base64 编个码,方便传输)
在 web 领域最常见的 token 解决方案是 JWT(JSON Web Token),其具体实现可以参照官方文档,这里不再赘述。
token 的安全性类似 session 方案,与明文密码的差异主要在于过期时间。其作用流程也与 session 类似:
最后再强调一下:
token 的优势是无需服务器存储!!!
token 的优势是无需服务器存储!!!
token 的优势是无需服务器存储!!!
不要再犯把 token 存储到 session 或是数据库中这样的错误了。
接下来说一下认证信息在 客户端 存储的方式。首先明确,无论是明文用户名密码,还是 sessionId 和 token,都可以用三种方式存储,即 cookie、localStorage 和 sessionStorage。
但 cookie 和 local/session Storage 分工又有所不同,cookie 可以作为传递的参数,并可通过后端进行控制,local/session Storage 则主要用于在客户端中保存数据,其传输需要借助 cookie 或其它方式完成。
下面是三种方式的对比(参考文章 JS 详解 Cookie、 LocalStorage 与 SessionStorage )
特性 | cookie | localStorage | sessionStorage |
---|---|---|---|
生命周期 | 一般由服务器生成,可设置失效时间。如果在浏览器端生成cookie,默认是关闭浏览器后失效 | 除非被清除,否则永久保存 | 仅在当前会话下有效,关闭页面或浏览器后被清除 |
数据大小 | 4K左右 | 一般为5MB | 一般为5MB |
通信方式 | 每次都会携带在HTTP头中,如果使用cookie保存过多数据会带来性能问题 | 仅在客户端(即浏览器)中保存,不参与和服务器的通信 | 同 localStorage |
通常来说,在可以使用 cookie 的场景下,作为验证用途进行传输的用户名密码、sessionId、token 直接放在 cookie 里即可。而后端传来的其它信息则可以根据需要放在 local/session Storage 中,作为全局变量之类进行处理。
终于可以粘代码了,但是在这之前还得再说两句。shiro 的安全管理实际上是基于会话实现的,所以我们没得选,用 session 方案就可以了。网上居然还有说 shiro + token 的,唉,这个问题槽点怎么这么多。。。
上节课我们分析了 subject.login()
背后的故事,但还有一点没说,就是该过程会产生 session,并自动把 sessionId 设置到 cookie。这个看 DelegatingSubject
类还看不出来,还要再继续深入分析源码。我们简单做个测试,使用 postman 发一个登录请求:
可以看到响应头中第一行设置的 JSESSIONID
,即 sessionId 在 tomcat 中的叫法。
好了,接下来我们来实现一下较为完善的访问拦截。上面说过,靠前端实现的拦截很容易被绕过。要想实现靠谱的拦截,必须由后端验证用户登录状态。这个思路并不难,就是前端带上 sesisonId 发送请求交由后端 认证,坑爹之处主要在于前后端分离的情况下需要额外的配置解决跨域问题。
默认的情况下,跨域的 cookie 是被禁止的,后端不能设置,前端也不能发送,所以两边都要设置。
首先编写一下拦截器 LoginInterceptor
,主要是修改 preHandle
方法
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {
// 放行 options 请求,否则无法让前端带上自定义的 header 信息,导致 sessionID 改变,shiro 验证失败
if (HttpMethod.OPTIONS.toString().equals(httpServletRequest.getMethod())) {
httpServletResponse.setStatus(HttpStatus.NO_CONTENT.value());
return true;
}
Subject subject = SecurityUtils.getSubject();
// 使用 shiro 验证
if (!subject.isAuthenticated()) {
return false;
}
return true;
}
由于跨域情况下会先发出一个 options 请求试探,这个请求是不带 cookie 信息的,所以 shiro 无法获取到 sessionId,将导致认证失败。这个地方坑了我好几个小时,要不是文章早就写完了,我什么都配置好了,请求发过来 sessionId 还是老变。也怪我一直盯着后端,没仔细看前端发的是啥请求。
之后,为了允许跨域的 cookie,我们需要在配置类 MyWebConfigurer
做一些修改,主要是 addCorsMappings
方法:
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowCredentials(true)
.allowedOrigins("http://localhost:8080")
.allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE")
.allowedHeaders("*")
}
这里注意,在 allowCredentials(true)
,即允许跨域使用 cookie 的情况下,allowedOrigins()
不能使用通配符 *,这也是出于安全上的考虑。
为了让前端能够带上 cookie,我们需要通过 axios 主动开启 withCredentials
功能,即在 main.js
中添加一行
axios.defaults.withCredentials = true
这样,前端每次发送请求时就会带上 sessionId,shiro 就可以通过 sessionId 获取登录状态并执行是否登录的判断。
现在还存在一个问题,即后端接口的拦截是实现了,但页面的拦截并没有实现,仍然可以通过伪造参数,绕过前端的路由限制,访问本来需要登录才能访问的页面。为了解决这个问题,我们可以修改 router.beforeEach
方法:
router.beforeEach((to, from, next) => {
if (to.meta.requireAuth) {
if (store.state.user) {
axios.get('/authentication').then(resp => {
if (resp) next()
})
} else {
next({
path: 'login',
query: {redirect: to.fullPath}
})
}
} else {
next()
}
}
)
即访问每个页面前都向后端发送一个请求,目的是经由拦截器验证服务器端的登录状态,防止上述情况的发生。后端这个接口可以暂时写成空的,比如:
@ResponseBody
@GetMapping(value = "api/authentication")
public String authentication(){
return "身份认证成功";
}
mark 一下这种思路,后来在权限控制中还要发挥重要作用。
上文提到 cookie 的生命周期如果未特别设置则与浏览器保持一致。我们看一下之前发送的登录请求的响应
JSESSIONID=C9D7C13C4C2444022AD865A23CA96189; Path=/; HttpOnly
并没有设置存活时间,所以在关闭浏览器后,sessionId 就会消失,再次发送请求,shiro 就会认为用户已经变更。但有时我们需要保持登录状态,不然每次都要重新登录怪麻烦的,所以 shiro 提供了 rememberMe 机制。
rememberMe 机制不是单纯地设置 cookie 存活时间,而是又单独保存了一种新的状态。之所以这样设计,也是出于安全性考虑,把 “记住我” 的状态与实际登录状态做出区分,这样,就可以控制用户在访问不太敏感的页面时无需重新登录,而访问类似于购物车、订单之类的页面时必须重新登录。
为了启用 rememberMe,我们需要修改 shiro 配置类,添加两个方法:
public CookieRememberMeManager rememberMeManager() {
CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
cookieRememberMeManager.setCookie(rememberMeCookie());
cookieRememberMeManager.setCipherKey("EVANNIGHTLY_WAOU".getBytes());
return cookieRememberMeManager;
}
@Bean
public SimpleCookie rememberMeCookie() {
SimpleCookie simpleCookie = new SimpleCookie("rememberMe");
simpleCookie.setMaxAge(259200);
return simpleCookie;
}
之后,在登录方法中设置 UsernamePasswordToken
的 rememberMe
属性
···
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, requestUser.getPassword());
usernamePasswordToken.setRememberMe(true);
try {
subject.login(usernamePasswordToken);
···
}
这时再看登录方法的响应头,发现多了一条关于 rememberMe 的设置
里面有我们配置的存活时间 Max-Age=259200
,单位是秒,259200 即 30 天。
在拦截器中进行具体的判断逻辑,由于目前我们并没有特殊需求,所以姑且两种状态都放行:
if (!subject.isAuthenticated() && !subject.isRemembered()) {
return false;
}
可以通过
System.out.println(subject.isRemembered());
System.out.println(subject.isAuthenticated());
测试一下,当正常登录时,控制台的输出为
关闭浏览器,直接访问需要登录的页面,仍然可以进入,但控制台的输出为
此外,可以通过在登录时设置单选框的方式,让用户自行决定是否启用记住我功能。
希望大家已经大致弄清楚 shiro 的作用以及如何在前后端之前传递认证相关的信息了。下一篇计划讲解根据用户权限动态渲染页面(菜单),又是一个大工程,我尽量在两周之内写完吧,再次感谢大家的支持与耐心等待。
再多聊几句,凑个字数。近期有三个人问我怎么理财,他们分别是我的读者、同事和高中同学,我给的建议核心都是俩字:存钱。 你不要去相信网上那些铺天盖地的理财教程,什么复利是世界第八大奇迹,股市是年轻人的又一次跃升机会之类,要真这样他们哪还有功夫教你,都赶去跃升了。这些课程都是漏斗模型,层层筛选出最傻最好骗的韭菜,有点良心的无非是卖贵点的课给你,不要节操的会让你贷款上杠杆炒币炒空气炒到怀疑人生。
理财是必要的,但它的意义在于 财务安全,即通过一定的知识让自身或者家庭不陷入由经济问题引起的危机。想要靠咱们手里的这点钱理财理出财务自由,难度堪比上小学的卢姥爷和 faker 打成五五开。人人都知道股市的二八定律,然而人人都以为自己是那能挣钱的两成人。可以说当你从没接触过股市却抱有这种期待的那一刻起,你就成为了一棵合格的韭菜。
财务安全,一是要管控风险,即自己不了解的东西不碰,比如一个理财产品收益特别高,还号称没有风险,你想不明白为什么,就离它远远的。二是要未雨绸缪,为未来大概率出现的事情做准备,比如步入职场你就要考虑买房结婚买车生子教育父母养老。头一两年没心没肺玩一玩无所谓,大家都是这么过来的,但这些问题你越早面对,将来承担的压力就越小。其实本来大家自发地也都能学会存钱、稳稳过日子,奈何现在挥舞镰刀的人越来越多,能保证自己不被收割就算是大智慧喽。
最后,如果大家觉得我的文章有所帮助,可以点赞关注收藏评论什么的走一波,这些数据都很重要,对我是一种实打实的鼓励。
上一篇:Vue + Spring Boot 项目实战(十三):使用 Shiro 实现用户信息加密与登录认证
下一篇:Vue + Spring Boot 项目实战(十五):动态加载后台菜单