49、实现shiro无状态访问(自定义token)

前言 http协议是无状态协议。浏览器访问服务器时,要让服务器知道你是谁,只有两种方式:

  • 方式一:把“你是谁”写入cookie。它会随每次HTTP请求带到服务端;
  • 方式二:在URL、表单数据中带上你的用户信息(也可能在HTTP头部)。这种方式依赖于从特定的网页入口进入,因为只有走特定的入口,才有机会拼装出相应的信息,提交到服务端。
大部分SSO需求都希望不依赖特定的网页入口(集成门户除外),所以后一种方式有局限性。适应性强的方式是第一种,即在浏览器通过cookie保存用户信息相关凭据,随每次请求传递到服务端。本文的方案是第一种。
如果了解shiro可以知道,shiro默认使用ServletContainerSessionManager来做 session管理,它是依赖于浏览器的cookie来维护 session的,调用storeSessionId方法保存sesionId到cookie中。很多情况下,我们需要前端与后台是分离的且跨域的,比如手机APP登录或者第三方非浏览器端登录,依靠浏览器默认的session管理是没法满足我们的需要的,所以我们需要实现前后端分离,使用自定义token实现用户登录或验证通过的情况,那么面对的问题就是自定义会话的改造。那么如何改造session呢。
首先,我们来看看shiro的整体架构图,如下所示。49、实现shiro无状态访问(自定义token)
文章图片

SecurityManager中包含了Authenticator认证部分、Authrizer权限部分以及、Session管理和缓存管理,那么我们只需要默认改造SessionManager即可,也就是改造shiro的默认会话管理DefaultWebSessionManager。那么我们只要实现自定义的会话管理即可。
主要设计两点:
(1)如何从Http头或Http参数中获取对应token并将token有session一一映射
(2)在登录的时候如何获取token并返回给前端
实现步骤 (1)自定义会话管理
shiro的默认会话管理器是DefaultWebSessionManager,我们只需要自己实现DefaultWebSessionManager并覆盖其对应方法getSessionId即可将自定义的session返回给shiro,实现token与session的一一映射。
package com.dondown.session; import java.io.Serializable; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import org.apache.shiro.web.servlet.Cookie; import org.apache.shiro.web.servlet.ShiroHttpServletRequest; import org.apache.shiro.web.servlet.SimpleCookie; import org.apache.shiro.web.session.mgt.DefaultWebSessionManager; import org.apache.shiro.web.util.WebUtils; import org.springframework.util.StringUtils; /** * 目的: shiro 的 session 管理 *自定义session规则,实现前后分离,在跨域等情况下使用token方式进行登录验证才需要,否则没必须使用本类。 *shiro默认使用 ServletContainerSessionManager来做 session管理, *它是依赖于浏览器的 cookie来维护 session的,调用 storeSessionId方法保存sesionId到cookie中 *为了支持无状态会话,我们就需要继承 DefaultWebSessionManager *自定义生成sessionId则要实现 SessionIdGenerator * 备注说明: * @author Administrator */ public class ShiroSessionManager extends DefaultWebSessionManager{ // 定义的请求参数中使用的标记key,用来传递 token private static final String AUTH_TOKEN = "access_token"; //private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request"; public ShiroSessionManager(){ super(); }/** * 获取sessionId,原本是根据sessionKey来获取一个sessionId * 重写的部分多了一个把获取到的token设置到request的部分。 * 这是因为app调用登陆接口的时候,是没有token的,登陆成功后,产生了token,我们把它放到request中,返回结果给客户端的时候, * 把它从request中取出来,并且传递给客户端,客户端每次带着这个token过来,就相当于是浏览器的cookie的作用,也就能维护会话了 * @param request * @param response * @return */ @Override protected Serializable getSessionId(ServletRequest request, ServletResponse response) { // 获取请求参数中的 AUTH_TOKEN 的值,如果请求参数中有 AUTH_TOKEN 则其值为sessionId。shiro就是通过sessionId 来控制的 String sessionId = WebUtils.toHttp(request).getParameter(AUTH_TOKEN); if (StringUtils.isEmpty(sessionId)){ // 如果没有携带id参数则按照父类的方式在cookie进行获取sessionId return super.getSessionId(request, response); } else { // 是否将sid保存到cookie,浏览器模式下使用此参数。 if (WebUtils.isTrue(request, "__cookie")){ Cookie template = getSessionIdCookie(); Cookie cookie = new SimpleCookie(template); cookie.setValue(sessionId); cookie.saveTo(WebUtils.toHttp(request), WebUtils.toHttp(response)); } // session来源于哪里:url request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, ShiroHttpServletRequest.URL_SESSION_ID_SOURCE); // 请求参数中如果有 access_token, 则其值为sessionId request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId); request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE); return sessionId; } } }

这里要注意的是:
A、有token的时候使用自定义token,否则使用默认的token获取实现。
B、有token的时候返回token并且存储到对应请求中。
(2)应用自定义会话管理器
在shiro配置时候注入自己的会话管理
/** * 自定义的 shiro session 缓存管理器,用于跨域等情况下使用 token 进行验证,不依赖于sessionId * @return */ @Bean public SessionManager sessionManager(){ // 将我们继承后重写的shiro session 注册 ShiroSessionManager shiroSession = new ShiroSessionManager(); // 如果后续考虑多tomcat部署应用,可以使用shiro-redis开源插件来做session的控制,或者nginx的负载均衡 shiroSession.setSessionDAO(new EnterpriseCacheSessionDAO()); return shiroSession; }/** * 注入shiro安全管理器设置realm认证 * @return */ @Bean("securityManager") public org.apache.shiro.mgt.SecurityManager securityManager(@Qualifier("myRealm") ShiroUserRealm MyRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); // 设置realm数据源 securityManager.setRealm(MyRealm); // 注入ehcache缓存管理器; //securityManager.setCacheManager(ehCacheManager()); securityManager.setCacheManager(redisCacheManager()); // 注入shiro自带的内存缓存管理器 //securityManager.setCacheManager(memoryCacheManager()); // 注入Cookie记住我管理器 // securityManager.setRememberMeManager(rememberMeManager()); // 自定义的shiro session 缓存管理器实现前后端分离无状态会话-自定义token而不是使用浏览器session securityManager.setSessionManager(sessionManager()); return securityManager; }

(3)登录成功返回对应token给前端
/** * 登录接口,地址需要与shiro的登录地址一一对应 * 登录后浏览器传过来的session与被后台记住,session与对应用户进行了绑定 * 可以通过SecurityUtils.getSubject(); 来获取当前用户session对应的认证信息 * @param loginName * @param password * @param request * @param session * @param response * @return */ @RequestMapping(value = "https://www.it610.com/login", method = RequestMethod.GET) @ResponseBody public ReturnValue authenticate(@RequestParam("userName") String loginName, @RequestParam("password") String password, HttpServletRequest request, HttpServletResponse response) {// 把前端输入的username和password封装为token // 使用realm指定的盐值+password进行配置的算法类型加密(加密次数也是配置) // 然后与数据库存储的秘钥解密后进行匹配(根据存储为HEX或Base64解密) //UsernamePasswordToken token = new UsernamePasswordToken(loginName, EncryptUtil.md5(password)); UsernamePasswordToken token = new UsernamePasswordToken(loginName, password); // 认证身份 Subject subject = SecurityUtils.getSubject(); try { subject.login(token); log.info("******登陆成功******"); // 设置session时间 //SecurityUtils.getSubject().getSession().setTimeout(1000*60*30); // 登录成功则返回token,用于无状态会话 return new ReturnValue(subject.getSession().getId().toString()); } catch (UnknownAccountException e){ log.info("******用户不存在******"); return new ReturnValue(ErrorCode.ERROR_OBJECT_EXIST, "该用户不存在!"); } catch (LockedAccountException e){ log.info("******用户未启用******"); return new ReturnValue(ErrorCode.ERROR_USER_PASSWORD, "该用户被锁定!"); } catch (DisabledAccountException e){ log.info("******用户未启用******"); return new ReturnValue(ErrorCode.ERROR_USER_PASSWORD, "该用户未启用!"); }catch (Exception e) { log.info("******未知错误******"); return new ReturnValue(ErrorCode.ERROR_SERVER_ERROR, "未知错误,用户登录失败,请联系管理员!"); } }

重点就是:return new ReturnValue(subject.getSession().getId().toString()); 从当前的Subject中获取会话id并返回给前端。
(4)处理跨域预检验请求
前后端分离项目中,由于跨域,会导致复杂请求,即会发送preflighted request,这样会导致在GET/POST等请求之前会先发一个OPTIONS请求,但OPTIONS请求并不带shiro的令牌,所以会被拦截导致无法发送GET或POST请求。所以我们直接拦截并允许即可。
package com.dondown.session; import java.io.PrintWriter; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.shiro.web.filter.authc.FormAuthenticationFilter; import com.alibaba.fastjson.JSONObject; import com.dondown.error.ErrorCode; import com.dondown.error.ReturnValue; /** * 目的: 过滤OPTIONS请求 *继承shiro 的form表单过滤器,对 OPTIONS 请求进行过滤。 *前后端分离项目中,由于跨域,会导致复杂请求,即会发送preflighted request,这样会导致在GET/POST等请求之前会先发一个OPTIONS请求,但OPTIONS请求并不带shiro *的'authToken'字段(shiro的SessionId),即OPTIONS请求不能通过shiro验证,会返回未认证的信息。 * 备注说明: 需要在 shiroConfig 进行注册 */ public class CORSAuthenticationFilter extends FormAuthenticationFilter { // 直接过滤可以访问的请求类型 private static final String REQUET_TYPE = "OPTIONS"; public CORSAuthenticationFilter() { super(); } @Override public boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if (((HttpServletRequest) request).getMethod().toUpperCase().equals(REQUET_TYPE)) { return true; } return super.isAccessAllowed(request, response, mappedValue); } @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { HttpServletResponse res = (HttpServletResponse)response; res.setHeader("Access-Control-Allow-Origin", "*"); res.setStatus(HttpServletResponse.SC_OK); res.setCharacterEncoding("UTF-8"); PrintWriter writer = res.getWriter(); writer.write(JSONObject.toJSONString(new ReturnValue(ErrorCode.ERROR_NOT_LOGIN, "请先登录系统!"))); writer.close(); return false; } }

此时配置我们的shiro并设置我们的过滤器:
@Bean public ShiroFilterFactoryBean shirFilter(org.apache.shiro.mgt.SecurityManager securityManager) { // shiroFilterFactoryBean对象 ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); // 配置shiro安全管理器 SecurityManager shiroFilterFactoryBean.setSecurityManager(securityManager); // 指定要求登录时的链接 shiroFilterFactoryBean.setLoginUrl("/login"); // 登录成功后要跳转的链接 shiroFilterFactoryBean.setSuccessUrl("/index"); // 未授权时跳转的界面; //shiroFilterFactoryBean.setUnauthorizedUrl("/403"); // 配置拦截器. Map filterChainDefinitionMap = new LinkedHashMap(); // 配置退出 过滤器,其中的具体的退出代码Shiro已经替我们实现了 filterChainDefinitionMap.put("/logout", "anon"); filterChainDefinitionMap.put("/afterlogout", "anon"); // 配置访问权限 // 过滤链定义,从上向下顺序执行,一般将/**放在最为下边 -->:这是一个坑呢,一不小心代码就不好使了; // authc:所有url都必须认证通过才可以访问; anon:所有url都都可以匿名访问 filterChainDefinitionMap.put("/static/**", "anon"); filterChainDefinitionMap.put("/templates/**", "anon"); filterChainDefinitionMap.put("/swagger-*/**", "anon"); filterChainDefinitionMap.put("/swagger-ui.html/**", "anon"); filterChainDefinitionMap.put("/webjars/**", "anon"); filterChainDefinitionMap.put("/v2/**", "anon"); filterChainDefinitionMap.put("/afterlogin", "anon"); // add操作,该用户必须有【addOperation】权限 // filterChainDefinitionMap.put("/add", "perms[addOperation]"); // 表示admin权限才可以访问 // filterChainDefinitionMap.put("/admin/**", "roles[admin]"); filterChainDefinitionMap.put("/**", "authc"); filterChainDefinitionMap.put("/**/*", "authc"); // 拦截器工厂类注入 shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); // 自定义拦截器限制并发人数 LinkedHashMap filtersMap = new LinkedHashMap<>(); // 统计登录人数:限制同一帐号同时在线的个数 //filtersMap.put("kickout", kickoutSessionControlFilter()); // 自定义跨域前后端分离验证过滤器-自定义token情况 filtersMap.put("corsAuthenticationFilter", new CORSAuthenticationFilter()); shiroFilterFactoryBean.setFilters(filtersMap); return shiroFilterFactoryBean; }

(5)前端带自定义token测试
http://192.168.8.8:7004/shiro/user/find/lixx?access_token=登录返回的token值
49、实现shiro无状态访问(自定义token)
文章图片

可以发现使用自定义token即可穿过网关访问后台服务或夸网服务。
【49、实现shiro无状态访问(自定义token)】快来成为我的朋友或合作伙伴,一起交流,一起进步!:
QQ群:961179337
微信:lixiang6153
邮箱:lixx2048@163.com
公众号:IT技术快餐

    推荐阅读