SpringBoot整合shiro解决前后台分离 vue跨域OPTIONS预请求问题

前后分离,使用自定义token作为shiro认证标识,实现springboot整合shiro

直接进入主题,项目是使用springboot,框架用的shiro做权限,mybatis做orm框架,项目需要做前后分离,这样就会导致一个问题,shiro是根据sessionID来识别是不是同一个request,但如果前后分离的话,就会出现跨域的问题,session很可能就会发生变化,这样就需要用一个标记来表明是同一个请求。初步的方案就是用token来代替session,但本质上说,现在的这种方式,还是用的session的那一套,不过是对中间进行了处理,下面上代码:

我们要先解决的是跨域的问题:

springboot解决跨域很好解决,如下即可

package com.common.config.cors;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

/**
 * @author :RanGe
 * 创建时间: 2020-01-08 22:08
 * 目的: 跨域访问控制, 做前后分离的话,这个也是必配的
 * 备注说明:
 */
@Configuration
public class CorsConfig {

    private CorsConfiguration buildConfig() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        // 允许任何域名使用
        corsConfiguration.addAllowedOrigin("*");
        // 允许任何头
        corsConfiguration.addAllowedHeader("*");
        // 允许任何方法(post、get等)
        corsConfiguration.addAllowedMethod("*");
        return corsConfiguration;
    }


    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        // 对接口配置跨域设置
        source.registerCorsConfiguration("/**", buildConfig());
        return new CorsFilter(source);
    }
}

然后自定义 realm ,简单点说,就是你实现查询用户角色和权限的类。这一步就省略了,不外乎查询数据库,查询当前用户的角色和权限。

接着自定义token,简单的说,这里其实就是让前端请求的时候在请求头中带一个特定的标识,然后根据这个标识找到vlues,匹配上我们的sessionId。

package com.common.config.shiro;

import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.util.StringUtils;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.Serializable;

/**
 * @author :RanGe
 * 创建时间: 2020-01-08 22:15
 * 目的: shiro 的 session 管理
 *      自定义session规则,实现前后分离,在跨域等情况下使用token 方式进行登录验证才需要,否则没必须使用本类。
 *      shiro默认使用 ServletContainerSessionManager 来做 session 管理,它是依赖于浏览器的 cookie 来维护 session 的,
 * 		调用 storeSessionId  方法保存sesionId 到 cookie中
 *      为了支持无状态会话,我们就需要继承 DefaultWebSessionManager
 *      自定义生成sessionId 则要实现 SessionIdGenerator
 * 备注说明:
 */
public class ShiroSession extends DefaultWebSessionManager {

    /**
     * 定义的请求头中使用的标记key,用来传递 token
     */
    private static final String AUTH_TOKEN = "authToken";

    private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";


    public ShiroSession() {
        super();
        //设置 shiro session 失效时间,默认为30分钟,这里现在设置为15分钟
        //setGlobalSessionTimeout(MILLIS_PER_MINUTE * 15);
    }



    /**
     * 获取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).getHeader("AUTH_TOKEN");
        if (StringUtils.isEmpty(sessionId)){
            //如果没有携带id参数则按照父类的方式在cookie进行获取sessionId
            return super.getSessionId(request, response);

        } else {
            //请求头中如果有 authToken, 则其值为sessionId
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
            //sessionId
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
            return sessionId;
        }
    }

}

大家看上面的代码,其实就是我们的请求头中获取 authToken 的值,然后塞入到sessionId中,代替了session.

如果大家还需要自定义这个token,或者说自定义生成的seesionId,就需要看下面的这个方法。

根据我的研究,最终找到 JavaUuidSessionIdGenerator 这个类,然后可以找到

	public Serializable generateId(Session session) {
		return UUID.randomUUID().toString();
	}

上面的代码,其实就是生成了一串UUID,我们可以实现SessionIdGenerator接口来完成自定义的sessionID生成

public class UuidSessionIdGenerator implements SessionIdGenerator{
 
	@Override
	public Serializable generateId(Session session) {
		Serializable uuid = new JavaUuidSessionIdGenerator().generateId(session);
		GGLogger.info("生成的sessionid是:"+uuid);
		return uuid;
	}
}
###自定义生成sessionid
sessionIdGenerator=ggauth.shiro.user.common.UuidSessionIdGenerator
securityManager.sessionManager.sessionDAO.sessionIdGenerator=$sessionIdGenerator

上面自定义生成的代码是 参考的 https://blog.csdn.net/yaomingyang/article/details/78142763 的代码,未经过验证,但应该是没问题的。我这里是没有去修改生成UUID的逻辑。

其实配置了这2个东西之后,就可以弄shiro最终的配置了。

package com.common.config.shiro;

import com.yunji.kwxt.common.Constant;
import com.yunji.kwxt.common.filter.CORSAuthenticationFilter;
import org.apache.shiro.cache.ehcache.EhCacheManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.session.mgt.eis.EnterpriseCacheSessionDAO;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.Map;

/**
* @author :RanGe
 * 创建时间: 2020-01-08 22:35
 * 目的: shiro配置
 * 备注说明:
 */
@Configuration
public class ShiroConfig {

    private static Logger log = LoggerFactory.getLogger(ShiroConfig.class);
    /**
     * 对shiro的拦截器进行注入
     * <p>
     * securityManager:
     * 所有Subject 实例都必须绑定到一个SecurityManager上,SecurityManager 是 Shiro的核心,初始化时协调各个模块运行。
     * 然而,一旦 SecurityManager协调完毕,
     * SecurityManager 会被单独留下,且我们只需要去操作Subject即可,无需操作SecurityManager 。 
     * 但是我们得知道,当我们正与一个 Subject 进行交互时,实质上是
     * SecurityManager在处理 Subject 安全操作
     *
     * @param securityManager
     * @return
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilter(org.apache.shiro.mgt.SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
        shiroFilter.setSecurityManager(securityManager);
        //设置遇到未登录、未授权等情况时候,请求这些地址,返回相应的错误
        shiroFilter.setLoginUrl("/user/shiroError?errorId=" + Constant.NEED_LOGIN);
        shiroFilter.setUnauthorizedUrl("/user/shiroError?errorId=" + Constant.NO_UNAUTHORIZED);
        //拦截器,配置访问权限 必须是LinkedHashMap,因为它必须保证有序。滤链定义,从上向下顺序执行,一般将 /**放在最为下边
        Map<String, String> filterMap = new LinkedHashMap<String, String>();
        // 配置不会被拦截的链接 顺序判断
        filterMap.put("/user/login", "anon");
        filterMap.put("/user/shiroError", "anon");
        filterMap.put("/user/reg", "anon");
        //剩余的请求shiro都拦截
        filterMap.put("/**/*", "authc");
        shiroFilter.setFilterChainDefinitionMap(filterMap);

        //自定义拦截器
        Map<String, Filter> customFilterMap = new LinkedHashMap<>();
        customFilterMap.put("corsAuthenticationFilter", new CORSAuthenticationFilter());
        shiroFilter.setFilters(customFilterMap);
        return shiroFilter;
    }

    /**
     * securityManager 核心配置
     * 安全控制层
     * @return
     */
    @Bean
    public org.apache.shiro.mgt.SecurityManager securityManager(){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        //设置自定义的realm
        defaultWebSecurityManager.setRealm(myRealm());
        //自定义的shiro session 缓存管理器
        defaultWebSecurityManager.setSessionManager(sessionManager());
        //将缓存对象注入到SecurityManager中
        defaultWebSecurityManager.setCacheManager(ehCacheManager());
        return defaultWebSecurityManager;
    }

    /**
     * 自定义的realm
     * @return
     */
    @Bean
    public MyRealm myRealm() {
        return new MyRealm();
    }

    /**
     * 开启shiro 的AOP注解支持
     * @param securityManager
     * @return
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(org.apache.shiro.mgt.SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }

    /**
     * shiro缓存管理器
     * 1 添加相关的maven支持
     * 2 注册这个bean,将缓存的配置文件导入
     * 3 在securityManager 中注册缓存管理器,之后就不会每次都会去查询数据库了,相关的权限和角色会保存在缓存中,
     * 但需要注意一点,更新了权限等操作之后,需要及时的清理缓存
     */
    @Bean
    public EhCacheManager ehCacheManager() {
        EhCacheManager cacheManager = new EhCacheManager();
        cacheManager.setCacheManagerConfigFile("classpath:config/ehcache.xml");
        return cacheManager;
    }

    /**
     * 自定义的 shiro session 缓存管理器,用于跨域等情况下使用 token 进行验证,不依赖于sessionId
     * @return
     */
    @Bean
    public SessionManager sessionManager(){
        //将我们继承后重写的shiro session 注册
        ShiroSession shiroSession = new ShiroSession();
        //如果后续考虑多tomcat部署应用,可以使用shiro-redis开源插件来做session 的控制,或者nginx 的负载均衡
        shiroSession.setSessionDAO(new EnterpriseCacheSessionDAO());
        return shiroSession;
    }
}

完成了上面的流程,基本就已经大功告成了,对了,还要加上下面的代码。

package com.common.filter;

import com.alibaba.fastjson.JSONObject;
import com.yunji.kwxt.common.Constant;
import com.yunji.kwxt.common.enums.ResultEnum;
import com.yunji.kwxt.common.model.ResultJson;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;

/**
 * @author :RanGe
 * 创建时间: 2020-01-08 22:55
 * 目的: 过滤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();
        ResultJson resultJson = new ResultJson(Constant.ERROR_CODE_NO_LOGIN, ResultEnum.ERROR.getStatus(), "请先登录系统!", null);
        writer.write(JSONObject.toJSONString(resultJson));
        writer.close();
        return false;
    }
}

为什么要过滤,上面的注释说的很清楚了,建议大家还是加上,这个类最终在shiro的拦截器那里配置了。

当然还有登录那里要说一下,很多的新手不然就搞不懂了。

/**
     * 用户登录
     * @param username 用户名
     * @param password 用户密码
     * @return
     */
    @RequestMapping(value = "/login", method = RequestMethod.POST)
    @ResponseBody
    public ResultJson login(String username, String password, HttpServletRequest request){

        //TODO 验证码验证
        UsernamePasswordToken token = new UsernamePasswordToken(username, password);
        User user = userService.login(username, password);
        SecurityUtils.getSubject().login(token);

        //更新登录信息
        user.setIp(HttpTool.getIpAddr(request));
        user.setOs(HttpTool.getOs(request));
        user.setUpdateUserId(user.getId());
        user.setUpdateTime(CommonTool.getTimestamp());

        //设置session时间
        //SecurityUtils.getSubject().getSession().setTimeout(1000*60*30);

        //token信息
        Subject subject = SecurityUtils.getSubject();
        Serializable tokenId = subject.getSession().getId();
        return new ResultJson(null, ResultEnum.SUCCESS.getStatus(), "登录认证成功", tokenId);
    }

我们最终从shiro的session中取到了sessionId,回传给前端,前端后续的请求都要带这个token。

这样就实现了token方式的shiro整合springboot。

如果为了安全,还可以建议大家,获取到sessionId 之后,我们进行一次加密,然后返回给前端,前端返回给我们的时候,我们可以在shirosession 类中对加密的sessionId解密,这样就更安全了。

最后还有一个问题需要说明一下,上述的代码中shiro使用了缓存,但我的缓存相关的配置却没有贴出来,因为我这里用的是java的缓存框架,建议使用redis的缓存框架,如果使用了缓存框架,细心的小伙伴就会发现,如果登录后,在一定时间没有和后台进行交互,这个sessionId就会失效。

这是因为,当我们登录后如果走了缓存,session的存活时间就被缓存管理起来,我们即使设置了shiro的缓存时间,设置应用的缓存时间都无法管理到第三方的缓存,shiro的sesssion和server的session不是同一个东西。他并不是servlet来管理的,故而设置了也没有作用,需要去设置缓存中这个对象存活时间才有用,比如我们弄了redis来管理sessionId,只有设置了在redis中session的存活时间才行,我们直接设置

SecurityUtils.getSubject().getSession().setTimeout(1000*60*30);
#session过期时间,单位秒
server.servlet.session.timeout=30000

都没有任何用。比如我上面用ehcache来管理缓存,那只有在该缓存框架中设置这个参数才有用

我这里设置了120S,那如果120S没有任何交互,那这个缓存sessionId就会失效

vue前端配置跨域以及token设置

在前后端完全分离的情况下,Vue项目中实现token验证大致思路如下:

1、第一次登录的时候,前端调后端的登陆接口,发送用户名和密码

2、后端收到请求,验证用户名和密码,验证成功,就给前端返回一个token

3、前端拿到token,将token存储到localStorage和vuex中,并跳转路由页面

4、前端每次跳转路由,就判断 localStroage 中有无 token ,没有就跳转到登录页面,有则跳转到对应路由页面

5、每次调后端接口,都要在请求头中加token

6、后端判断请求头中有无token,有token,就拿到token并验证token,验证成功就返回数据,验证失败(例如:token过期)就返回401,请求头中没有token也返回401

7、如果前端拿到状态码为401,就清除token信息并跳转到登录页面

vue-cli搭建一个项目,简单说明前端要做的事:
在这里插入图片描述

一、调登录接口成功,在回调函数中将token存储到localStorage和vuex中
login.vue

<template>
  <div>
    <input type="text" v-model="loginForm.username" placeholder="用户名"/>
    <input type="text" v-model="loginForm.password" placeholder="密码"/>
    <button @click="login">登录</button>
  </div>
</template>
 
<script>
import { mapMutations } from 'vuex';
export default {
  data () {
    return {
      loginForm: {
        username: '',
        password: ''
      },
      userToken: ''
    };
  },
 
  methods: {
    ...mapMutations(['changeLogin']),
    login () {
      let _this = this;
      if (this.loginForm.username === '' || this.loginForm.password === '') {
        alert('账号或密码不能为空');
      } else {
        this.axios({
          method: 'post',
          url: '/user/login',
          data: _this.loginForm
        }).then(res => {
          console.log(res.data);
          _this.userToken = 'Bearer ' + res.data.data.body.token;
          // 将用户token保存到vuex中
          _this.changeLogin({ Authorization: _this.userToken });
          _this.$router.push('/home');
          alert('登陆成功');
        }).catch(error => {
          alert('账号或密码错误');
          console.log(error);
        });
      }
    }
  }
};
</script>

在这里插入图片描述

store文件夹下的index.js

import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
 
const store = new Vuex.Store({
 
  state: {
    // 存储token
    Authorization: localStorage.getItem('Authorization') ? localStorage.getItem('Authorization') : ''
  },
 
  mutations: {
    // 修改token,并将token存入localStorage
    changeLogin (state, user) {
      state.Authorization = user.Authorization;
      localStorage.setItem('Authorization', user.Authorization);
    }
  }
});
 
export default store;

二、路由导航守卫
router文件夹下的index.js

import Vue from 'vue';
import Router from 'vue-router';
import login from '@/components/login';
import home from '@/components/home';
 
Vue.use(Router);
 
const router = new Router({
  routes: [
    {
      path: '/',
      redirect: '/login'
    },
    {
      path: '/login',
      name: 'login',
      component: login
    },
    {
      path: '/home',
      name: 'home',
      component: home
    }
  ]
});
 
// 导航守卫
// 使用 router.beforeEach 注册一个全局前置守卫,判断用户是否登陆
router.beforeEach((to, from, next) => {
  if (to.path === '/login') {
    next();
  } else {
    let token = localStorage.getItem('Authorization');
 
    if (token === 'null' || token === '') {
      next('/login');
    } else {
      next();
    }
  }
});
 
export default router;

三、请求头加token

// 添加请求拦截器,在请求头中加token
axios.interceptors.request.use(
  config => {
    if (localStorage.getItem('Authorization')) {
      config.headers.AUTH_TOKEN = localStorage.getItem('Authorization');
    }
    return config;
  },
  error => {
    return Promise.reject(error);
  });

四、如果前端拿到状态码为401,就清除token信息并跳转到登录页面

localStorage.removeItem('Authorization');
this.$router.push('/login');

至此 问题完美修复, 喜欢的老铁给个双击!

发布了26 篇原创文章 · 获赞 0 · 访问量 296

猜你喜欢

转载自blog.csdn.net/weixin_41601114/article/details/103899003