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)_第1张图片
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 = "/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值
在这里插入图片描述
可以发现使用自定义token即可穿过网关访问后台服务或夸网服务。

快来成为我的朋友或合作伙伴,一起交流,一起进步!:
QQ群:961179337
微信:lixiang6153
邮箱:[email protected]
公众号:IT技术快餐

你可能感兴趣的:(大数据)