一、场景描述

1、前言

基于上一篇spring boot 整合shiro,当时想把shiro引入到我们的spring boot中,引入完了之后,我发现一个问题,那就是shiro是基于session的。那么我是不是可以引入到接口来做呢?比如我们的会员系统,会员分为好几个级别【白银、黄金、铂金、钻石、星耀、王者、荣耀王者】,目前只有王者和荣耀王者才可以打巅峰赛。

2、模拟逻辑(纯属杜撰)

我们先模拟一种场景
角色有:silver、gold、platinum、diamonds、starshine、king、glory
会员有:user1、user2、user3…userX
权限有:common_activity(普通活动)、star_activity(星耀活动)、king_activity(王者活动)、call_up_mode(征召模式)、common_mode(普通竞技模式)…

我们可以说,首先配置角色和权限关系
1、silver、gold、platinum、diamonds、starshine、king、glory 都有 common_activity、common_mode
2、diamonds、starshine、king、glory 都有call_up_mode
2、starshine、king、glory 都有 star_activity
3、king、glory 都有 king_activity

写到这里,大家觉得也没毛病啊,就用shiro做权限管理,没问题的,但是因为我们兼容安卓和IOS,所以是不是涉及到前后端分离呢?这样session无效了,怎么办。

3、JWT如何配合shiro来完成权限

这个相关的文章一大堆:
1、spring-boot-shiro-jwt-redis实现登陆授权功能
2、spring-boot-shiro-jwt-redis

这类文章太多了,我们就不在赘述了,不过这里有一个东西可以注意一下。

 /*
         * 关闭shiro自带的session,详情见文档
         * http://shiro.apache.org/session-management.html#SessionManagement-
         * StatelessApplications%28Sessionless%29
         */
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);

这玩意如果不知道在哪?可以看看上面2篇参考文章。这里是说要把session关闭的一个逻辑,同时备注上也说了,参考:关闭shiro自带的session,详情见文档

二、度娘告诉我们的做法

1、每次都需要登录

我们可以结合他的shiroConfig对于JwtFilter的注入知道,每个请求,都必须先登录,然后在做权限判断。

JwtFilter

/**
     * 执行登录认证
     *
     * @param request
     * @param response
     * @param mappedValue
     * @return
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        try {
            executeLogin(request, response);
            return true;
        } catch (Exception e) {
            throw new AuthenticationException("Token失效请重新登录");
        }
    }

    /**
     *
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader(CommonConstant.ACCESS_TOKEN);

        JwtToken jwtToken = new JwtToken(token);
        // 提交给realm进行登入,如果错误他会抛出异常并被捕获
        getSubject(request, response).login(jwtToken);
        // 如果没有抛出异常则代表登入成功,返回true
        return true;
    }

shiroRealm类

/**
     * 功能: 用来进行身份认证,也就是说验证用户输入的账号和密码是否正确,获取身份验证信息,错误抛出异常
     *
     * @param auth 用户身份信息 token
     * @return 返回封装了用户信息的 AuthenticationInfo 实例
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
        String token = (String) auth.getCredentials();
        if (token == null) {
            log.info("————————身份认证失败——————————IP地址:  " + CommonUtils.getIpAddrByRequest(SpringContextUtils.getHttpServletRequest()));
            throw new AuthenticationException("token为空!");
        }
        // 校验token有效性
        SysUser loginUser = this.checkUserTokenIsEffect(token);
        return new SimpleAuthenticationInfo(loginUser, token, getName());
    }

    /**
     * 校验token的有效性
     *
     * @param token
     */
    public SysUser checkUserTokenIsEffect(String token) throws AuthenticationException {
        // 解密获得username,用于和数据库进行对比
        String username = JwtUtil.getUsername(token);
        if (username == null) {
            throw new AuthenticationException("token非法无效!");
        }

        // 查询用户信息
        SysUser loginUser = new SysUser();
        SysUser sysUser = sysUserService.getUserByName(username);
        if (sysUser == null) {
            throw new AuthenticationException("用户不存在!");
        }

        // 校验token是否超时失效 & 或者账号密码是否错误
        if (!jwtTokenRefresh(token, username, sysUser.getPassWord())) {
            throw new AuthenticationException("Token失效请重新登录!");
        }

        // 判断用户状态
        if (!"0".equals(sysUser.getDelFlag())) {
            throw new AuthenticationException("账号已被删除,请联系管理员!");
        }

        BeanUtils.copyProperties(sysUser, loginUser);
        return loginUser;
    }

2、我解决的问题

你有没有发现,每次调用登录,其实每次都是在调用数据库,这样对于性能是否有所损害呢?所以我们想要改变点什么对不对?

2.1、我发现按照度娘的做法,session依然有效

/*
         * 关闭shiro自带的session,详情见文档
         * http://shiro.apache.org/session-management.html#SessionManagement-
         * StatelessApplications%28Sessionless%29
         */
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);

实验证明,利用这段代码,我依然可以正常登录,说明session依然是有效的,这个事情就比较奇怪了,我命名禁用了session的存储,但是其实底层本质就是一个hash,所以不存储也没关系,全局变量还是帮我搞定了,咋办?参考文章:Shiro禁用Session,使用SSM+JWT+Shiro进行无状态RESTful API
这篇文章中,有这么一段代码

<!--Shiro-->
<!-- Realm实现 -->
<bean id="statelessRealm" class="shiro.MyStatelessRealm">
    <property name="cachingEnabled" value="false"/>
</bean>
<!-- 禁用掉会话调度器 -->
<bean id="sessionManager" class="org.apache.shiro.session.mgt.DefaultSessionManager">
    <property name="sessionValidationSchedulerEnabled" value="false"/>
</bean>
<!--解决报错,组装默认的subjectDAO-->
<bean id="subjectDAO" class="org.apache.shiro.mgt.DefaultSubjectDAO">
    <property name="sessionStorageEvaluator" ref="sessionStorageEvaluator"/>
</bean>
<bean id="sessionStorageEvaluator" class="org.apache.shiro.mgt.DefaultSessionStorageEvaluator">
    <property name="sessionStorageEnabled" value="false"/>
</bean>
<!-- 安全管理器 -->
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <property name="realm" ref="statelessRealm"/>
    <property name="subjectDAO" ref="subjectDAO"/>
    <property name="sessionManager" ref="sessionManager"/>
</bean>
<!-- 相当于调用SecurityUtils.setSecurityManager(securityManager) -->
<bean class="org.springframework.beans.factory.config.MethodInvokingFactoryBean">
    <property name="staticMethod" value="org.apache.shiro.SecurityUtils.setSecurityManager"/>
    <property name="arguments" ref="securityManager"/>
</bean>

翻译过来是这样子的

    @Bean
    public SecurityManager securityManager(){
        DefaultWebSecurityManager securityManager =  new DefaultWebSecurityManager();
        /*
         * 关闭shiro自带的session,详情见文档
         * http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
         */
        Logging.getLogger().info("关闭shiro自带的session");
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();

        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);

        DefaultSessionManager defaultSessionManager = new DefaultSessionManager();
        defaultSessionManager.setSessionValidationSchedulerEnabled(false);


        securityManager.setSubjectDAO(subjectDAO);
        securityManager.setRealm(myShiroRealm());
        securityManager.setSessionManager(defaultSessionManager);

        return securityManager;
    }

至此,我终于不能通过session去登录了,but,我发现另外一个问题,url上面,会带有sessionid,怎么办?最后我们发现shiro-1.3.2版本,有个比较好的设置,配置如下

@Bean
    public SecurityManager securityManager(){
        DefaultWebSecurityManager securityManager =  new DefaultWebSecurityManager();
        /*
         * 关闭shiro自带的session,详情见文档
         * http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
         */
        Logging.getLogger().info("关闭shiro自带的session");
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();

        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);

        DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager();
        defaultWebSessionManager.setSessionIdCookieEnabled(false);
        defaultWebSessionManager.setSessionIdUrlRewritingEnabled(false);

        securityManager.setSubjectDAO(subjectDAO);
        securityManager.setRealm(myShiroRealm());
        securityManager.setSessionManager(defaultWebSessionManager);

        return securityManager;
    }

这时候你发现你session怎么弄都没用了,且url不会带有sessionid了。

2.2、优化mysql改为redis

其实也就是把这一块改成缓存的
将信息查询改成缓存的
这样子的话,每次授权也都是可以的了,不用再担心性能问题,另外session问题也彻底失效了。

3、理想是丰满的

本以为按照之前的步骤,是比较顺畅的,不过我发现,filter根本加载不了bean,当然有参考文章:在springboot中拦截器Filter中注入bean失败解决方案,以上的解决方案,我并没有实验,我是觉得这种方案已经偏离了我的心中所想,所以接下来,我准备用aop的方式来做token这块的东西,登录还是按照之前的逻辑来做。参考文章:springmvc 在controller层使用aop切面编程,这里引发了我的一个思考,就是感觉很多时候做法有很多,但是偏离了自己心中所想的话,自己的知识很难形成体系,所以我调转过来用aop这种最纯粹的方式来做jwt,最后通过登录的方式,怼入到shiro中去。

4、理想再一次是丰满的

我原本的打算是,在config里面释放api模块

filterChainDefinitionMap.put("/api/**", "anon");

然后通过aop的方式

@Pointcut(value = "execution( * com.cs.commonapi.controller.api.*.*(..))")
    public void brokerAspect() {
    }

    /**
     * before可以简写成@Before("execution( * com.cs.commonapi.controller.api.*.*(..))")
     * 这样就不需要定义切面了
     * @param point
     */
    @Before("brokerAspect()")
    public void doBefore(JoinPoint point) {
        RequestBO requestBO = getRequestInfo();
        if(!doValidToken(requestBO)){
            return;
        }
        doLogin(requestBO);
    }

最后在doLogin方法里面做shiro的登录,可以登录,但是当我写 @RequiresPermissions(“vip”) 的时候我发现完了,@RequiresPermissions注解竟然比我的aop优先执行,这时候发现理想再一次是丰满的,回想起度娘上的方法,也许这是我最后的归途,因为shiro里面定制过滤器肯定比他的注解先执行,于是乎就这么干。

6、完成

通过设置jwtFilter的方式,提前进行了登录,后面操作相对来说比较正常,紧接着有一个问题,就是发现bean的加载,在filter之后,导致了我filter加载的时候,bean还有加载。主要代码如下:
入口文件:有@SpringBootApplication注解的入口

ConfigurableApplicationContext context = SpringApplication.run(CommonApiApplication.class, args);
SpringContextUtil.setApplicationContext(context);

SpringContextUtil 类

public class SpringContextUtil{

    private static ApplicationContext applicationContext;

    public static void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        SpringContextUtil.applicationContext = applicationContext;
    }

    /**
     * 获取上下文
     */
    public static ApplicationContext getApplicationContext() {
        return applicationContext;
    }
    /**
     * 通过 bena 名称获取上下文中的 bean
     */
    public static Object getBean(String name) {
        return applicationContext.getBean(name);
    }
    /**
     * 通过类型获取上下文中的bean
     */
    public static Object getBean(Class<?> requiredType) {
        return applicationContext.getBean(requiredType);
    }
}

JwtFilter 类的部分内容

public class JwtFilter extends AccessControlFilter {

    private JwtData jwtData;
    private RedisUtil redisUtil;

    /**
     * 验证白名单,有些东西不需要认证的。
     */
    private String[] uriWhiteList = {
            "/api/auth/create"
    };

    private void initBean(){
        jwtData = (JwtData)SpringContextUtil.getBean("jwtData");
        redisUtil = (RedisUtil)SpringContextUtil.getBean("redisUtil");
    }
}

通过这种方式,将我的逻辑串联起来了。但是我发现认证虽然做了缓存,但是获取权限接口,会有多次调用,所以为了加快效率,这个地方也行缓存处理。
对了缓存,我是这么做的,给一个获取用户信息的缓存。用了一组重载来做的。

public class UserCache {

    private static final String USER_CACHE_PRE = "API-USER-CACHE:";
    private static final int CACHE_TIME = 7200;

    public static ShiroUser get(RedisUtil redisUtil,ShiroUserService shiroUserService,String userName,boolean isForceSelect){
        if(redisUtil.hasKey(createKey(userName)) && !isForceSelect){
            String userJson = redisUtil.get(createKey(userName)).toString();
            Logging.getLogger().info("从缓存中获取");
            return JSONArray.parseObject(userJson.getBytes(),ShiroUser.class);
        }

        Logging.getLogger().info("从数据库中获取");
        ShiroUser shiroUser = shiroUserService.queryByUserName(userName);
        redisUtil.set(createKey(userName),JSONArray.toJSON(shiroUser).toString(),CACHE_TIME);
        return shiroUser;
    }

    public static ShiroUser get(RedisUtil redisUtil,ShiroUserService shiroUserService,String userName){
        return get(redisUtil,shiroUserService,userName,false);
    }

    private static String createKey(String userName){
        return USER_CACHE_PRE+userName;
    }
}

针对Filter里面的异常无法抛出的问题:Springboot2.x 整合shiro 解决自定义异常无法抛出问题给个传送门,此文章比较长,切勿心浮气躁,可以解决问题,有必要看完

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐