Shiro实现session和jwt认证共存【补充篇】

6 篇文章 1 订阅

前言

前文 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可以使用其它参数名

难点解决

*禁用session管理

这个非常重要,如果关闭,不能实现有状态的token(即session)管理,开启着会影响无状态请求,会导致各种莫名其妙的bug。

由于系统采用redis作为缓存管理,查找办法就是把redis关闭掉,操作的时候就会触发redis连接导致请求无响应和超时,再一一解决。

三禁-禁session写入、禁session读取、禁权限读取,都涉及到有状态。

官方文档

https://shiro.apache.org/session-management.html#SessionManagement-SessionsSubjectState-HybridApproach

解疑:经过重复试验,在多realm共存的情况下,全部禁用session管理或者开启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的缓存读取

禁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方案整合shiro

禁缓存才是最大的难点,整合简单多了,前提是得理解一些概念,不然也是会搞得乱七八糟,前文从未提及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());
	}

业务使用

善于使用SubjectPrincipal ,只要搭的好,无状态有状态都可以用的,即使无状态,在一次请求的生命周期里是有状态的,和有状态的用法一模一样,可以通过Principal在业务里传递用户信息。

Subject subject = SecurityUtils.getSubject();
Principal principal = (Principal) subject.getPrincipal();

最后

之前的文章不清不楚,这篇文章算是完美解答了,思路和疑难基本解决。至于使用无状态之后衍生出来的问题那就是后续的头疼了。在中小规模的项目中,我有个应用方案就是把无状态这套当做后备隐藏救急方案,就是当灾难发生时假如redis全挂了,就是用不了,系统可以马上切换到无状态realm。又或者是认证使用jwt减少对redis的耦合,提高系统的高可用,需要鉴权再再加载有状态内容,这也是非常不错的,至于完全的无状态,那是不现实的。

  • 5
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值