前文 Shiro实现session和无状态token认证共存 保姆级代码,但是不够完善,有些难点不清不楚,这里补充一些难点的解决。
当时选型 shiro复用session实现前后端分离鉴权 ,纯粹采用有状态的鉴权方案简单强大,而真正意义上的的有状态和无状态共存在shiro上是不好实现的,当然我也给你解决了。
如果不了解,赶紧止步,请使用有状态方案,真正了解无状态并坚决落地无状态再来看实现。
无状态有状态的比喻:
前文经典比喻
session与jwt的不同:session认证是保险箱在服务器,密码在用户手中,用户把密码送到服务器解开自己的保险箱,而jwt则是保险箱放在用户手中,服务器什么都不放,当用户把保险箱送来,服务器摸一摸保险箱,敲打敲打,认为保险箱是自己家生产的就打开它。
这样当服务器开分号时,采用session方式就只能帮用户解锁在自己分号的保险箱,用户如果让a分号打开存在b分号的保险箱,就得顺丰快递从a送到b送过来。而jwt方式每一家分号都能打开任意用户的保险箱。
补充一下上面比喻,假如保险箱非常多,明显session方案成本在存储保险箱,jwt方案成本在用户得搬运保险箱。redis集群虽然也耗带宽但是可以搭建在内网,相当于分号内部建立的超时空通道。
运维成本:
所谓的有状态无状态关键在于,session存储信息在服务端,jwt存储信息在客户端。
抓住要点,这意味着高负荷下有状态耗费服务器内存,无状态耗费外网网络带宽。
假设权限丰富的token平均大小为5kb,这是非常正常的。
200w session存储大概占用10g内存。2000qps jwt传输占用10MB/s网速即80M下载带宽。我在这里假定的数据是非常有实际意义的,这个价位的云服务器,10g内存大致对应的就是百M下载带宽,2000qps 非常不错了,这还是因为下载带宽相对上传带宽不值钱,如果你要搞jwt刷新策略返回参数带新jwt就要消耗上传带宽了,这个就是高成本了。很明显200w的session的系统级别远远不是2000qps的级别系统可比的。也就是说成本上来说肯定存储服务器性价比高,所谓的有状态浪费服务器资源不攻自破,无状态还浪费带宽呢。
网上乱象:
在研发权限丰富的系统中,我更倾向使用session的方式。近年来无状态兴起,各种博文动不动就贬低session一无是处,强行给session编了一大堆缺点(要么是根本不懂乱写,要么是没本事解决),其实session才是主流方案,方便控制-权限更新、踢人下线、限制登录等,无状态token不适合小项目趟这个浑水,更适合大型互联网项目的部分功能,它非常适用于只需要认证而鉴权需求少的局部的微服务。我前两篇文章是在2020年1月写的,那时候无状态吹得是牛逼轰轰,动不动就无状态鉴权,现在是2022年3月风向好像倒回去,估计是被坑的人越来越多了。
不过直到现在很多文章的评论里都混淆无状态、有状态、token、jwt、session、sessionId、cookie、localStorage。随处可见干了好几年的程序员写出session依赖cookie在手机端不能用,必须使用token或者jwt、无状态认证 的评论。
入门八股文必有一问session和cookie的区别?评论session离不开cookie的绝大多数都背过这个八股文,不然也入不了行,网上的八股文解答很细致,也都能答出什么cookie只能存4k之类的。。。
概诉作用:
这个问题就是个陷阱,不用讲它们的区别,它们压根就是两个东西,只不过历史场景经常绑在一起使用,session代表有状态会话,包含用户登录状态、权限信息,存在服务器的内存之中,比较大,生成一个sessionId的短字符串作为key通过response请求设置cookie并传送给客户端,客户端浏览器就会自动使用cookie存储,这样即使前端不编写传参,访问同域url时会自动带上cookie非常方便。sessionId存在客户端哪都行和cookie无关,只是方便不用前端自己编写传参逻辑,比如安卓小程序之类cookie不能用的场景,sessionId可以前端自己编写保存到存储里,统一拦截请求时带到请求头或者参数里都行,后端也得编写逻辑去拦截获取。这里sessionId通常会被后端改个好听的名字叫做token,token只是个参数名而已,这里的token就是sessionId就是有状态token。而jwt的token才是无状态的,说白了jwt的token就是session的内容加时间之类,你要是存权限之类的话就会非常长,要使用散列还是双向加密都行看你如何落地,不存服务器就得在传输时一直带上,jwt这套必须自己去实现这些传参保存方案,session+cookie设置这套框架自带,所以很多人误解只有token能用在移动端上 ,传来传去变成只有jwt能用在移动端上 。
歧路方案:
真正明白session和cookie和jwt的关系后,就不会走上歧路。比如当年我改造系统有了 shiro复用session实现前后端分离鉴权 这个方案,而在我开发之前的前辈的解决非常奇葩,cookie不能用后,就自己生成一串uuid保存redis叫做token,只能认证不能鉴权相当于shiro的user权限标识符。。。大概是因为不懂shiro框架,不懂如何在后端获取到sessionId,然后实现jwt又被哪篇水文忽悠了搞了个四不像。包括现在网上的文章很多jwt还给存到redis里,都存redis还能叫无状态吗。
无状态缺点:
无状态优点就是服务器不存储,然后为了实现踢下线,限制登录、权限变更,会衍生出各种有状态的方案。比如redis存储有变更user,jwt过来时需要比对user,搞到最后,服务器还是得存东西,所以无状态只是个理想概念,为了丰富功能最后还是得需要有状态来解决。
我想象中的无状态使用场景:
前面讲到有状态耗内存,无状态耗带宽。全部使用无状态不现实,我提倡精简jwt的token,大部分时候只需要认证不需要鉴权的功能使用jwt的token就行,然后用到某个涉及到鉴权的功能开始有状态,才读取权限内容保存进redis。这样可以解决无状态耗带宽的问题,又可以减低有状态对redis集群的过分依赖,防止redis一挂全部功能都得挂。redis集群存储个成千上亿的session虽然不成问题,但是web大集群和redis大集群频繁交互,每个请求都交互一次,耦合度实在太高了。
所以说完美的无状态不现实,权限信息不可能存入jwt,太大了,耗带宽,小项目更加承受不起这个成本。
1、自定义过滤器
2、多realm共存
3、重写supports选择realm进行认证
4、多realm共存鉴权失败抛出详细异常
5、认证失败时 返回json
6、授权失败时 返回json
7、获取sessionid可以使用其它参数名
这个非常重要,如果关闭,不能实现有状态的token(即session)管理,开启着会影响无状态请求,会导致各种莫名其妙的bug。
由于系统采用redis作为缓存管理,查找办法就是把redis关闭掉,操作的时候就会触发redis连接导致请求无响应和超时,再一一解决。
三禁-禁session写入、禁session读取、禁权限读取,都涉及到有状态。
官方文档
https://shiro.apache.org/session-management.html#SessionManagement-SessionsSubjectState-HybridApproach
解疑:经过重复试验,在多realm共存的情况下,全部禁用session管理或者开启session管理都没啥问题,但是混用的时候就不好处理了,得想办法处理,不修改源码的前提下,框架只能做到选择性禁止session的缓存写入。
官网混用方案只能禁登录时的session缓存写入,也就是说不会禁止缓存读取。
方法一:重写SessionStorageEvaluator
传参有Subject ,可以获取request 的内容,通过token的不同特征来区分是无状态的还是有状态的token
重写SessionStorageEvaluator:
@Service
public class CustomeSessionStorge extends DefaultWebSessionStorageEvaluator {
@Override
public boolean isSessionStorageEnabled(Subject subject) {
if(subject instanceof WebSubject){
HttpServletRequest request = (HttpServletRequest) ((WebSubject) subject).getServletRequest();
String token = request.getParameter("token");
if(StringUtils.isBlank(token)) {
token = ((HttpServletRequest) request).getHeader("token");
}
if (token == null || token.contains(".")) {
return false;
}
}
return super.isSessionStorageEnabled(subject);
}
}
配置:
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
subjectDAO.setSessionStorageEvaluator(customeSessionStorge);
securityManager.setSubjectDAO(subjectDAO);
方法二:NoSessionCreationFilter
https://shiro.apache.org/static/1.9.0/apidocs/org/apache/shiro/web/filter/session/NoSessionCreationFilter.html
session有提供一个过滤器用于禁止创建session,设置在登录接口上,效果可能比方法一更好。
noSessionCreation 过滤器设置到禁止创建session的接口上即可。
禁session的缓存读取,如果不设置,请求带无状态token时认证前会读取session。
重写 DefaultWebSessionManager getSessionId 方法
// 自己的token判断 我这里共用一个toekn参数
三层判断
第一个if判断包含.说明是jwt,sessionId直接返回null,就不会读缓存;
第二个if判断有状态token参数,变了传参形式的sessionId;
其它则是旧版的cookie传参的sessionId。
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
String sid = request.getParameter("token");
if(StringUtils.isBlank(sid)) {
sid = ((HttpServletRequest) request).getHeader("token");
}
if (StringUtils.isNotBlank(sid) && sid.contains(".")) {
return null;
} else if (StringUtils.isNotBlank(sid)) {
// 是否将sid保存到cookie,浏览器模式下使用此参数。
if (WebUtils.isTrue(request, "__cookie")){
HttpServletRequest rq = (HttpServletRequest)request;
HttpServletResponse rs = (HttpServletResponse)response;
Cookie template = getSessionIdCookie();
Cookie cookie = new SimpleCookie(template);
cookie.setValue(sid); cookie.saveTo(rq, rs);
}
// 设置当前session状态
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,
ShiroHttpServletRequest.URL_SESSION_ID_SOURCE); // session来源与url
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sid);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
return sid;
} else {
return super.getSessionId(request, response);
}
}
禁了前两步,访问带权限标识符的需要鉴权的接口时还会请求redis。调试后发现StatelessAuthorizingRealm的缓存确实禁用了,但是因为鉴权会遍历realm调用了有状态的Realm导致请求了redis。
解决办法就是重写所有的鉴权方法,判断是否无状态并结束调用。
所以前文不齐全,前文拦截的是doGetAuthorizationInfo方法,这个方法是—授权查询回调函数, 进行鉴权但缓存中无用户的授权信息时调用,也就是获取授权。所以要重写的是真正的鉴权方法isPermitted,鉴权方法会读取缓存,在前面判断拦截了,就不会读取。
加个判断,判断不是当前realm的principals,鉴权false。
@Override
public boolean isPermitted(PrincipalCollection principals, Permission permission) {
if(!(getAvailablePrincipal(principals) instanceof Principal)){
return false;
}
authorizationValidate(permission);
return super.isPermitted(principals, permission);
}
禁缓存才是最大的难点,整合简单多了,前提是得理解一些概念,不然也是会搞得乱七八糟,前文从未提及jwt,这里作补充。
不重要的全部省略掉,重点在于SecurityUtils.getSubject() ,subject调用login,然后再subject获取Principal得到token。
@RequestMapping(value = "。。。/login2")
@ResponseBody
public ResultDTO login2(HttpServletRequest request。。。) {
。。。
Subject subject = SecurityUtils.getSubject();
try {
if (subject == null) {
。。。
} else {
String username = request.getParameter("username");
String password = request.getParameter("password");
subject.login(new StatelessToken(username, password));
String jwt = ((StatelessAuthorizingRealm.Principal)SecurityUtils.getSubject().getPrincipal()).getToken();
。。。
}
拦截认证,token无效返回失败,有效调用login。关键点在于需要login。有状态的时候框架自动根据sessionId获取用户信息,而无状态每次调用都要login,不然鉴权的时候会报错。
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
StatelessToken statelessToken = new StatelessToken();
String token = request.getParameter("token");
if(StringUtils.isBlank(token)) {
token = ((HttpServletRequest) request).getHeader("token");
}
if(JwtUtil.verify(token)) {
// 验证成功
statelessToken.setToken(token);
getSubject(request, response).login(statelessToken);
return true;
}
// statelessToken.setToken(token);
// getSubject(request, response).login(statelessToken);
ResultDTO retDto = null;
。。。
response.getWriter().write(GsonUtils.toJson(retDto));
return false;
}
这里体现了无状态和有状态的最大不同。有状态只需要登录认证就行了,而无状态需要获取是否包含jwt的token,有的话说明登陆过,千万不要再去登录,而是解析token获取用户信息设置到SimpleAuthenticationInfo里,这一步其实相当于session版shiro内置读取session缓存,后面的和有状态实现一样,踢人不行了这得有状态去实现。
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken) {
StatelessToken token = (StatelessToken) authcToken;
if(StringUtils.isNotBlank(token.getToken())) {
User user = JwtUtil.getUsername(token.getToken());
Principal principalTmp = new Principal(user);
principalTmp.setToken(token.getToken());
return new SimpleAuthenticationInfo(principalTmp, null, null, getName());
}
// 校验用户名密码
User user = getSystemService().getUserByTLoginName(token.getUsername());
if (user != null) {
if (。。。) {
throw new AuthenticationException("msg:该帐号已禁止登录.");
} else if (。。。) {
throw new AuthenticationException("msg:该帐号已被加入黑名单.");
}
byte[] salt = Encodes.decodeHex(user.getPassword().substring(。。。));
Principal principal = new Principal(user);
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
List<Menu> list = UserUtils.get。。。;
for (Menu menu : list) {
if (StringUtils.isNotBlank(menu.getPermission())) {
// 添加基于Permission的权限信息
for (String permission : StringUtils.split(menu.getPermission(), ",")) {
info.addStringPermission(permission);
}
}
}
// 添加用户权限
info.addStringPermission("user");
// 添加用户角色信息
for (Role role : user.getRoleList()) {
info.addRole(role.get。。。);
}
String jwt = JwtUtil.createJWT(user, info);
principal.setToken(jwt);
// 无痕登录 不打日志
if(token.isTraceless()) {
principal.setTraceless(true);
}
AuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(principal, user
.getPassword().substring(。。。), ByteSource.Util.bytes(salt), getName());
if(!token.isTraceless()) {
// 更新登录IP和时间
getSystemService().update。。。
// 记录登录日志
LogUtils.saveLog(。。。);
}
// 踢人
// int limitSessionSize = getSystemService().getSessionDao().limitSessions(user.getId()).size();
return authenticationInfo;
} else {
return null;
}
}
这是绝对理想化的获取授权信息,有状态版想更新缓存就更新缓存,无状态只能解析jwt获取,实时性就没了。要想实现又得引入redis的一套,比如修改过的user设置进redis。这里面获取授权信息每次都得查询redis看是否存在于名单里是否需要重新读取,这样完美的无状态又不能实现了。
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
if(!(getAvailablePrincipal(principals) instanceof Principal)){
return null;
}
Principal principal = (Principal) getAvailablePrincipal(principals);
return JwtUtil.getRoles(principal.getToken());
}
善于使用Subject、Principal ,只要搭的好,无状态有状态都可以用的,即使无状态,在一次请求的生命周期里是有状态的,和有状态的用法一模一样,可以通过Principal在业务里传递用户信息。
Subject subject = SecurityUtils.getSubject();
Principal principal = (Principal) subject.getPrincipal();
之前的文章不清不楚,这篇文章算是完美解答了,思路和疑难基本解决。至于使用无状态之后衍生出来的问题那就是后续的头疼了。在中小规模的项目中,我有个应用方案就是把无状态这套当做后备隐藏救急方案,就是当灾难发生时假如redis全挂了,就是用不了,系统可以马上切换到无状态realm。又或者是认证使用jwt减少对redis的耦合,提高系统的高可用,需要鉴权再再加载有状态内容,这也是非常不错的,至于完全的无状态,那是不现实的。