springboot+shiro+jwt+redis+cache实现无状态token登录(完美好文章!!)

目录

(一)总览篇

一、前言

二、相关说明

2-1. Shiro + JWT实现无状态鉴权机制

2-2. 关于AccessToken及RefreshToken概念说明

2-3. 关于Redis中保存RefreshToken信息(做到JWT的可控性)

2-4. 关于根据RefreshToken自动刷新AccessToken

三、项目结构

(二)授权篇

一、Maven配置

二、Application配置

三、颁发Token

四、清除Token

五、演示说明

(三) 鉴权篇

一、Maven配置

二、Application配置

三、重写过滤器

四、自定义Realm

五、Shiro配置

六、重写Shiro缓存

6-1. 重写Shiro Cache为Redis

6-2. 重写Shiro缓存管理器

七、自定义异常

八、获取当前登录用户

九、效果演示

十、 At Last


(一)总览篇

一、前言

  在微服务中我们一般采用的是无状态登录,而传统的session方式,在前后端分离的微服务架构下,如继续使用则必将要解决跨域sessionId问题、集群session共享问题等等。这显然是费力不讨好的,而整合shiro,却很不恰巧的与我们的期望有所违背:
  (1)shiro默认的拦截跳转都是跳转url页面,而前后端分离后,后端并无权干涉页面跳转。
  (2)shiro默认使用的登录拦截校验机制恰恰就是使用的session。
  这当然不是我们想要的,因此如需使用shiro,我们就需要对其进行改造,那么要如何改造呢?我们可以在整合shiro的基础上自定义登录校验,继续整合JWT,或者oauth2.0等,使其成为支持服务端无状态登录,即token登录。
  本次将通过三篇博文带你实现shiro整合JWT无状态登录,这一篇主要做个整体介绍,先有个总体思路再撸码能让你少走弯路。在后续篇章中,我将一步步带大家把服务搭建起来,同时在该系列的最后我会把源码提供给大家。(ps:该篇文章在实现过程中参考了大量资料,当时实现匆忙未记录下来,侵删。)
  Here we go.

二、相关说明

2-1. Shiro + JWT实现无状态鉴权机制

  1. 首先post用户名与密码到login进行登入,如果成功在请求头Header返回一个加密的Authorization,失败的话直接返回10001未登录等状态码,以后访问都带上这个Authorization即可。

  2. 鉴权流程主要是要重写shiro的入口过滤器BasicHttpAuthenticationFilter,在此基础上进行拦截、token验证授权等操作

2-2. 关于AccessToken及RefreshToken概念说明

  1. AccessToken:用于接口传输过程中的用户授权标识,客户端每次请求都需携带,出于安全考虑通常有效时长较短。

  2. RefreshToken:与AccessToken为共生关系,一般用于刷新AccessToken,保存于服务端,客户端不可见,有效时长较长。

2-3. 关于Redis中保存RefreshToken信息(做到JWT的可控性)

  1. 登录认证通过后返回AccessToken信息(在AccessToken中保存当前的时间戳和帐号),同时在Redis中设置一条以帐号为Key,Value为当前时间戳(登录时间)的RefreshToken,现在认证时必须AccessToken没失效以及Redis存在所对应的RefreshToken,且RefreshToken时间戳和AccessToken信息中时间戳一致才算认证通过,这样可以做到JWT的可控性,如果重新登录获取了新的AccessToken,旧的AccessToken就认证不了,因为Redis中所存放的的RefreshToken时间戳信息只会和最新的AccessToken信息中携带的时间戳一致,这样每个用户就只能使用最新的AccessToken认证。

  2. Redis的RefreshToken也可以用来判断用户是否在线,如果删除Redis的某个RefreshToken,那这个RefreshToken所对应的AccessToken之后也无法通过认证了,就相当于控制了用户的登录,可以剔除用户

2-4. 关于根据RefreshToken自动刷新AccessToken

  1. 本身AccessToken的过期时间为5分钟(配置文件可配置),RefreshToken过期时间为30分钟(配置文件可配置),当登录后时间过了5分钟之后,当前AccessToken便会过期失效,再次带上AccessToken访问JWT会抛出TokenExpiredException异常说明Token过期,开始判断是否要进行AccessToken刷新,首先redis查询RefreshToken是否存在,以及时间戳和过期AccessToken所携带的时间戳是否一致,如果存在且一致就进行AccessToken刷新。

  2. 刷新后新的AccessToken过期时间依旧为5分钟(配置文件可配置),时间戳为当前最新时间戳,同时也设置RefreshToken中的时间戳为当前最新时间戳,刷新过期时间重新为30分钟过期(配置文件可配置),最终将刷新的AccessToken存放在Response的Header中的Authorization字段返回。

  3. 同时前端进行获取替换,下次用新的AccessToken进行访问即可。

三、项目结构

首先要明确以下项目的拆分结构仅为了演示用,并不一定适合你,根据各自项目实际情况拆分即可。效果图如下:
项目结构

  1. springboot-shiro-jwt-common:放置公共常量、配置等。
  2. springboot-shiro-jwt-redis:redis封装。
  3. springboot-shiro-jwt-web:web接口提供方,token鉴权。
  4. springboot-shiro-jwt-sso:登入登出、token授权及消除。

(二)授权篇

 上文总览篇中,相信大家已经对接下来要做的事情有了总体思路及印象。总言之我们要做的就只有两件事,一是授权,二即是鉴权。
  让我们先从授权开始,何为授权?在这里简单地来讲就是要颁发token。何时颁发?毫无疑问,无非就是在登录/注册成功之后。
  至于上文中提到的根据RefreshToken自动刷新AccessToken,我将之归置为token刷新,代码实现于后续篇章说明。
  Here we go.

一、Maven配置

<!-- jwt -->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>${java-jwt.version}</version>
</dependency>

二、Application配置

server:
  port: 8001

spring:
  application:
    name: springboot-shiro-jwt-sso
  # profiles: springboot-shiro-jwt-sso

  ## Redis配置 - start
  redis:
    # Redis数据库索引(默认为0)
    database: 1
    # Redis服务器地址
    host: 127.0.0.1
    # Redis服务器连接端口
    port: 6379
    # Redis服务器连接密码(默认为空)
    # password: "doufuplus"
    # 连接超时时间(毫秒)
    timeout: 5000
    jedis:
      pool:
        # 连接池最大连接数(使用负值表示没有限制)
        max-active: 8
        # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: -1
        # 连接池中的最大空闲连接
        max-idle: 8
        # 连接池中的最小空闲连接
        min-idle: 0
  ## Redis配置 - end


  ## 时间格式配置 - start
  jackson:
    serialization:
      write-dates-as-timestamps: true
  ## 时间格式配置 - end


## product配置 - start
info:
  app.name: springboot-shiro-jwt-sso
  company.name: doufuplus
  build.artifactId: $project.artifactId$
  build.modelVersion: $project.modelVersion$
## product配置 - end


## 日志配置 - start
logging:
  level:
    com.nfgj.medical.service: DEBUG
## 日志配置 - end


## 其它配置 - start
config:
  # JWT认证加密私钥(Base64加密)
  encrypt-jwtKey: U0JBUElOENhspJrzkyNjQ1NA
  # AccessToken过期时间(秒)
  accessToken-expireTime: 600
  # RefreshToken过期时间(秒)
  refreshToken-expireTime: 604800
## 其它配置 - end

三、颁发Token

token的颁发并未有什么难度,主要是生成AccessToken放置于Header给前端。再生成RefreshToken保存于服务端即可。此处使用redis保存。

/**
 * 登录
 * 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
 *
 * @author 丶doufu
 * @date 2019/08/03
 */
@PostMapping("/login")
public Result login(String account, String password, HttpServletResponse response) {

    try {
        if (!("doufuplus".equals(account) && "123456".equals(password))) {
            return new Result(ResultCode.PASSWORD_ERROR, "account or password error.");
        }

        // 清除可能存在的shiro权限信息缓存
        if (redis.hasKey(RedisConstant.PREFIX_SHIRO_CACHE + account)) {
            redis.del(RedisConstant.PREFIX_SHIRO_CACHE + account);
        }

        // 设置RefreshToken,时间戳为当前时间戳,直接设置即可(不用先删后设,会覆盖已有的RefreshToken)
        String currentTimeMillis = String.valueOf(System.currentTimeMillis());
        redis.set(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account, currentTimeMillis,
                Integer.parseInt(refreshTokenExpireTime));

        // 从Header中Authorization返回AccessToken,时间戳为当前时间戳
        String token = JwtUtil.sign(account, currentTimeMillis);
        response.setHeader("Authorization", token);
        response.setHeader("Access-Control-Expose-Headers", "Authorization");

        return new Result().OK();
    } catch (Exception e) {
        e.printStackTrace();
        return new Result(ResultCode.ERROR, e.getMessage());
    }
}

四、清除Token

没有买卖就没有伤害,有登录就会有退出。token的清除主要是做两件事:

  1. 清除可能存在的shiro权限信息
  2. 清除RefreshToken
/**
 * 退出
 * 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
 *
 * @author 丶doufu
 * @date 2019/08/03
 */
@RequestMapping("/logout")
public Result logout() {
    try {
        String token = "";
        // 获取头部信息
        Enumeration<String> headerNames = request.getHeaderNames();
        while (headerNames.hasMoreElements()) {
            String key = (String) headerNames.nextElement();
            String value = request.getHeader(key);
            if ("Authorization".equalsIgnoreCase(key)) {
                token = value;
            }
        }
        // 校验token
        if (StringUtils.isBlank(token)) {
            return new Result(ResultCode.PARAM_ERROR);
        }
        String account = JwtUtil.getClaim(token, JwtConstant.ACCOUNT);
        if (StringUtils.isBlank(account)) {
            return new Result(ResultCode.NOT_LOGIN, "token失效或不正确.");
        }
        // 清除shiro权限信息缓存
        if (redis.hasKey(RedisConstant.PREFIX_SHIRO_CACHE + account)) {
            redis.del(RedisConstant.PREFIX_SHIRO_CACHE + account);
        }
        // 清除RefreshToken
        redis.del(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account);

        return new Result().OK();
    } catch (Exception e) {
        e.printStackTrace();
        return new Result(ResultCode.ERROR, e.getMessage());
    }
}

五、演示说明

  1. 登录成功,返回10200
    登录成功-1
  2. 查看Header,Authorization返回AccessToken信息登录成功-2

(三) 鉴权篇

上文授权篇中,我们已经完成了对token的颁发及清除,上述操作实际上并不需要真正与shiro进行整合。在这一篇章中我将会说明关于整合shiro后如何进行token的鉴权,同时这也将是实现无状态登录鉴权的最后重头戏。

一、Maven配置

主要配置如下,具体引用请参照源码:

<!-- jwt -->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>${java-jwt.version}</version>
</dependency>

<!-- shiro -->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>${shiro-spring.version}</version>
</dependency>

<!-- mysql连接器 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

<!-- druid连接池 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>${druid.version}</version>
</dependency>

<!-- myBatis plus starter -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>${mybatis-plus-boot.version}</version>
</dependency>

<!-- myBatis plus -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus</artifactId>
    <version>${mybatis-plus.version}</version>
</dependency>

二、Application配置

server:
  port: 8002

spring:
  application:
    name: springboot-shiro-jwt-web
  # profiles: springboot-shiro-jwt-web

  ## 数据库配置 - start
  datasource:
    url: jdbc:mysql://localhost:3306/springboot-master?useSSL=false&allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&tinyInt1isBit=false
    username: root
    password: root
    type: com.alibaba.druid.pool.DruidDataSource
    # driver-class-name: com.mysql.cj.jdbc.Driver
    # 连接池配置
    druid:
      # 初始化大小,最小,最大
      initial-size: 5
      min-idle: 5
      max-active: 20
      # 配置获取连接等待超时的时间
      max-wait: 60000
      # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
      time-between-eviction-runs-millis: 60000
      # 配置一个连接在池中最小生存的时间,单位是毫秒
      min-evictable-idle-time-millis: 300000
      validation-query: SELECT 1 FROM DUAL
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false
      # 打开PSCache,并且指定每个连接上PSCache的大小
      pool-prepared-statements: true
      max-pool-prepared-statement-per-connection-size: 20
      # 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
      filters: stat,wall,log4j2
      use-global-data-source-stat: true
      # 通过connectProperties属性来打开mergeSql功能;慢SQL记录
      connect-properties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
      # 配置监控服务器
      stat-view-servlet:
        url-pattern: /druid/*
        login-username: admin
        login-password: 123456
        reset-enable: false
        # 添加IP白名单
        #allow:
        # 添加IP黑名单,当白名单和黑名单重复时,黑名单优先级更高
        #deny:
      web-stat-filter:
        # 添加过滤规则
        url-pattern: /*
        # 忽略过滤格式
        exclusions: "*.js,*.gif,*.jpg,*.jpeg,*.png,*.css,*.ico,/druid/*"
  ## 数据库配置 - end


  ## Redis配置 - start
  redis:
    # Redis数据库索引(默认为0)
    database: 1
    # Redis服务器地址
    host: 127.0.0.1
    # Redis服务器连接端口
    port: 6379
    # Redis服务器连接密码(默认为空)
    # password: "doufuplus"
    # 连接超时时间(毫秒)
    timeout: 5000
    jedis:
      pool:
        # 连接池最大连接数(使用负值表示没有限制)
        max-active: 8
        # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: -1
        # 连接池中的最大空闲连接
        max-idle: 8
        # 连接池中的最小空闲连接
        min-idle: 0
  ## Redis配置 - end


## mybatis配置 - start
mybatis-plus:
  # mapper.xml扫描
  mapper-locations: classpath*:/mapper/*.xml
  # 实体扫描,多个package用逗号或者分号分隔
  type-aliases-package: com.doufuplus.boot.shiro.entity
  global-config:
    db-config:
      # 主键类型
      id-type: UUID
      # 字段策略
      # field-strategy: DEFAULT
      # 数据库大写下划线转换
      capital-mode: true
      # 序列接口实现类配置
      # key-generator: com.baomidou.mybatisplus.core.incrementer
      # 逻辑删除配置
      logic-delete-value: 1
      logic-not-delete-value: 0

  configuration:
    # 开启自动驼峰命名规则
    map-underscore-to-camel-case: true
    cache-enabled: false
    call-setters-on-nulls: true
    # 打印SQL语句
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
## mybatis配置 - end


## product配置 - start
info:
  app.name: springboot-shiro-jwt-web
  company.name: doufuplus
  build.artifactId: $project.artifactId$
  build.modelVersion: $project.modelVersion$
## product配置 - end


## 日志配置 - start
logging:
  level:
    com.nfgj.medical.service: DEBUG
## 日志配置 - end


## 其它配置 - start
config:
  # JWT认证加密私钥(Base64加密)
  encrypt-jwtKey: U0JBUElOENhspJrzkyNjQ1NA
  # AccessToken过期时间(秒)
  accessToken-expireTime: 600
  # RefreshToken过期时间(秒)
  refreshToken-expireTime: 604800
  # Shiro缓存过期时间(秒)(一般设置与AccessToken过期时间一致) 此处CustomCache读取失败,待解决
  shiro-cache-expireTime: 600
## 其它配置 - end

三、重写过滤器

总览篇中我已提到,鉴权流程主要是重写shiro的入口过滤器BasicHttpAuthenticationFilter。重写主要是做三件事情:

  1. 判断请求是否需要进行登录认证授权(可在此写拦截白名单),如果需要则该请求就必须在Header中添加Authorization字段存放AccessToken,无需授权即游客直接访问(有权限管控的话,以游客访问就会被拦截)。
  2. 调用getSubject(request, response).login(token),将AccessToken提交给shiro中的UserRealm进行认证。
  3. AccessToken刷新:判断RefreshToken是否过期,未过期就返回新的AccessToken及RefreshToken并让请求继续正常访问。
/**
 * JWT过滤器
 * 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
 *
 * @author 丶doufu
 * @date 2019/08/03
 */
public class JwtFilter extends BasicHttpAuthenticationFilter {

    @Value("${config.refreshToken-expireTime}")
    private String refreshTokenExpireTime;

    @Autowired
    private RedisClient redis;

    /**
     * 这里我们详细说明下为什么最终返回的都是true,即允许访问 例如我们提供一个地址 GET /article 登入用户和游客看到的内容是不同的
     * 如果在这里返回了false,请求会被直接拦截,用户看不到任何东西 所以我们在这里返回true,Controller中可以通过
     * subject.isAuthenticated() 来判断用户是否登入
     * 如果有些资源只有登入用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可
     * 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        // 判断用户是否想要登入
        if (this.isLoginAttempt(request, response)) {
            try {
                // 进行Shiro的登录UserRealm
                this.executeLogin(request, response);
            } catch (Exception e) {
                // 认证出现异常,传递错误信息msg
                String msg = e.getMessage();
                // 获取应用异常(该Cause是导致抛出此throwable(异常)的throwable(异常))
                Throwable throwable = e.getCause();
                if (throwable != null && throwable instanceof SignatureVerificationException) {
                    // 该异常为JWT的AccessToken认证失败(Token或者密钥不正确)
                    msg = "token或者密钥不正确(" + throwable.getMessage() + ")";
                } else if (throwable != null && throwable instanceof TokenExpiredException) {
                    // 该异常为JWT的AccessToken已过期,判断RefreshToken未过期就进行AccessToken刷新
                    if (this.refreshToken(request, response)) {
                        return true;
                    } else {
                        msg = "token已过期(" + throwable.getMessage() + ")";
                    }
                } else {
                    // 应用异常不为空
                    if (throwable != null) {
                        // 获取应用异常msg
                        msg = throwable.getMessage();
                    }
                }
                /**
                 * 错误两种处理方式 1. 将非法请求转发到/401的Controller处理,抛出自定义无权访问异常被全局捕捉再返回Response信息 2.
                 * 无需转发,直接返回Response信息 一般使用第二种(更方便)
                 */
                // 直接返回Response信息
                this.response401(request, response, msg);
                return false;
            }
        }
        return true;
    }

    /**
     * 这里我们详细说明下为什么重写 可以对比父类方法,只是将executeLogin方法调用去除了
     * 如果没有去除将会循环调用doGetAuthenticationInfo方法
     */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        this.sendChallenge(request, response);
        return false;
    }

    /**
     * 检测Header里面是否包含Authorization字段,有就进行Token登录认证授权
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        // 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
        // String requestURI = ((HttpServletRequest) request).getRequestURI();
        // String token = this.getAuthzHeader(request);
        // return token != null;
        // 默认全部都需校验
        return true;
    }

    /**
     * 进行AccessToken登录认证授权
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        // 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
        JwtToken token = new JwtToken(this.getAuthzHeader(request));
        // 提交给UserRealm进行认证,如果错误他会抛出异常并被捕获
        this.getSubject(request, response).login(token);
        // 如果没有抛出异常则代表登入成功,返回true
        return true;
    }

    /**
     * 刷新AccessToken,进行判断RefreshToken是否过期,未过期就返回新的AccessToken且继续正常访问
     */
    private boolean refreshToken(ServletRequest request, ServletResponse response) {
        // 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
        String token = this.getAuthzHeader(request);
        // 获取当前Token的帐号信息
        String account = JwtUtil.getClaim(token, JwtConstant.ACCOUNT);
        // 判断Redis中RefreshToken是否存在
        if (redis.hasKey(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account)) {
            // Redis中RefreshToken还存在,获取RefreshToken的时间戳
            String currentTimeMillisRedis = redis.get(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account).toString();
            // 获取当前AccessToken中的时间戳,与RefreshToken的时间戳对比,如果当前时间戳一致,进行AccessToken刷新
            if (JwtUtil.getClaim(token, JwtConstant.CURRENT_TIME_MILLIS).equals(currentTimeMillisRedis)) {
                // 获取当前最新时间戳
                String currentTimeMillis = String.valueOf(System.currentTimeMillis());
                // 读取配置文件,获取refreshTokenExpireTime属性
                // PropertiesUtil.readProperties("config.properties");
                // String refreshTokenExpireTime =
                // PropertiesUtil.getProperty("refreshTokenExpireTime");
                // 设置RefreshToken中的时间戳为当前最新时间戳,且刷新过期时间重新为30分钟过期(配置文件可配置refreshTokenExpireTime属性)
                redis.set(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account, currentTimeMillis,
                        Integer.parseInt(refreshTokenExpireTime));
                // 刷新AccessToken,设置时间戳为当前最新时间戳
                token = JwtUtil.sign(account, currentTimeMillis);
                // 将新刷新的AccessToken再次进行Shiro的登录
                JwtToken jwtToken = new JwtToken(token);
                // 提交给UserRealm进行认证,如果错误他会抛出异常并被捕获,如果没有抛出异常则代表登入成功,返回true
                this.getSubject(request, response).login(jwtToken);
                // 最后将刷新的AccessToken存放在Response的Header中的Authorization字段返回
                HttpServletResponse httpServletResponse = (HttpServletResponse) response;
                httpServletResponse.setHeader("Authorization", token);
                httpServletResponse.setHeader("Access-Control-Expose-Headers", "Authorization");
                return true;
            }
        }
        return false;
    }

    /**
     * 无需转发,直接返回Response信息
     */
    private void response401(ServletRequest req, ServletResponse resp, String msg) {
        HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
        httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json; charset=utf-8");
        PrintWriter out = null;
        try {
            out = httpServletResponse.getWriter();
            String data = JsonConvertUtil.objectToJson(new Result(ResultCode.NOT_LOGIN, msg));
            out.append(data);
        } catch (IOException e) {
            throw new CustomException("直接返回Response信息出现IOException异常:" + e.getMessage());
        } finally {
            if (out != null) {
                out.close();
            }
        }
    }

    /**
     * 对跨域提供支持
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers",
                httpServletRequest.getHeader("Access-Control-Request-Headers"));
        // 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            httpServletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }
}

四、自定义Realm

与常规shiro一致,我们在Realm中做相关的身份、权限等认证授权

/**
 * 自定义Realm
 * 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
 *
 * @author 丶doufu
 * @date 2019/08/03
 */
@Service
public class UserRealm extends AuthorizingRealm {

    @Autowired
    private RedisClient redis;

    @Autowired
    private UserMapper userMapper;

    /**
     * 大坑,必须重写此方法,不然Shiro会报错
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        /*
        // 返回当前用户所拥有的角色、权限等信息,根据自身项目编码即可
        String account = JwtUtil.getClaim(principals.toString(), JwtConstant.ACCOUNT);
        // 查询用户角色
        List<Role> roles = roleMapper.findByAccount(account);
        for (int i = 0, roleLen = roles.size(); i < roleLen; i++) {
            Role role = roles.get(i);
            // 添加角色
            simpleAuthorizationInfo.addRole(role.getName());
            // 根据用户角色查询权限
            List<Permission> permissions = permissionMapper.findByRoleId(role.getId());
            for (int j = 0, perLen = permissions.size(); j < perLen; j++) {
                Permission permission = permissions.get(j);
                // 添加权限
                simpleAuthorizationInfo.addStringPermission(permission.getSn());
            }
        }
        */
        return simpleAuthorizationInfo;
    }

    /**
     * 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
        String token = (String) auth.getCredentials();
        if (StringUtils.isBlank(token)) {
            throw new AuthenticationException("token cannot be empty.");
        }

        // 解密获得account,用于和数据库进行对比
        String account = JwtUtil.getClaim(token, JwtConstant.ACCOUNT);
        // 帐号为空
        if (StringUtils.isBlank(account)) {
            throw new AuthenticationException("token中帐号为空(The account in Token is empty.)");
        }
        // 查询用户是否存在
        User user = userMapper.findByAccount(account);
        if (user == null) {
            throw new AuthenticationException("该帐号不存在(The account does not exist.)");
        }
        // 开始认证,要AccessToken认证通过,且Redis中存在RefreshToken,且两个Token时间戳一致
        if (JwtUtil.verify(token) && redis.hasKey(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account)) {
            // 获取RefreshToken的时间戳
            String currentTimeMillisRedis = redis.get(RedisConstant.PREFIX_SHIRO_REFRESH_TOKEN + account).toString();
            // 获取AccessToken时间戳,与RefreshToken的时间戳对比
            if (JwtUtil.getClaim(token, JwtConstant.CURRENT_TIME_MILLIS).equals(currentTimeMillisRedis)) {
                return new SimpleAuthenticationInfo(token, token, "userRealm");
            }
        }
        throw new AuthenticationException("token expired or incorrect.");
    }
}

五、Shiro配置

这里注意下关于JwtFilter的配置,由于spring boot中filter加载顺序原因,JwtFilter的Bean注入应放置于shiroFilter之后,否则将报如下异常:

No SecurityManager accessible to the calling code, either bound to the org.apache.shiro.util.
ThreadContext or as a vm static singleton. This is an invalid application configuration.

代码如下:

/**
 * Shiro配置
 * 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
 *
 * @author 丶doufu
 * @date 2019/08/03
 */
@Configuration
public class ShiroConfig {

    /**
     * 配置使用自定义Realm,关闭Shiro自带的session 详情见文档
     * http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
     */
    @Bean("securityManager")
    public DefaultWebSecurityManager getManager(UserRealm userRealm, RedisTemplate<String, Object> template) {
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        // 使用自定义Realm
        manager.setRealm(userRealm);
        // 关闭Shiro自带的session
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        manager.setSubjectDAO(subjectDAO);
        // 设置自定义Cache缓存
        manager.setCacheManager(new CustomCacheManager(template));
        return manager;
    }

    /**
     * 生成一个ShiroRedisCacheManager
     **/
    private CustomCacheManager cacheManager(RedisTemplate template) {
        return new CustomCacheManager(template);
    }

    /**
     * 添加自己的过滤器,自定义url规则 详情见文档 http://shiro.apache.org/web.html#urls-
     */
    @Bean("shiroFilter")
    public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
        // 添加自己的过滤器取名为jwt
        Map<String, Filter> filterMap = new HashMap<>(16);
        filterMap.put("jwtFilter", jwtFilterBean());
        factoryBean.setFilters(filterMap);
        factoryBean.setSecurityManager(securityManager);
        // 自定义url规则
        Map<String, String> filterRuleMap = new HashMap<>(16);
        // 所有请求通过我们自己的JWTFilter
        filterRuleMap.put("/**", "jwtFilter");
        factoryBean.setFilterChainDefinitionMap(filterRuleMap);
        return factoryBean;
    }

    /**
     * <pre>
     * 注入bean,此处应注意:
     *
     * (1)代码顺序,应放置于shiroFilter后面,否则报错:
     * 	No SecurityManager accessible to the calling code, either bound to the org.apache.shiro.util.
     * 	ThreadContext or as a vm static singleton. This is an invalid application configuration.
     *
     * (2)如不在此注册,在filter中将无法正常注入bean
     * </pre>
     */
    @Bean("jwtFilter")
    public JwtFilter jwtFilterBean() {
        return new JwtFilter();
    }

    /**
     * 下面的代码是添加注解支持
     */
    @Bean
    @DependsOn("lifecycleBeanPostProcessor")
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        // 强制使用cglib,防止重复代理和可能引起代理出错的问题,https://zhuanlan.zhihu.com/p/29161098
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }

    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(
            DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }
}

六、重写Shiro缓存

6-1. 重写Shiro Cache为Redis

/**
 * 重写Shiro的Cache保存读取
 * 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
 *
 * @author 丶doufu
 * @date 2019/08/03
 */
public class CustomCache<K, V> implements Cache<K, V> {

    // TODO redis @Autowired注入失败,因此改为下面采用传参形式
    // @Autowired
    // private RedisClient redis = new RedisClient();

    // TODO @Value注入失败 @Value("${config.shiro-cache-expireTime}")
    private String shiroCacheExpireTime = "600";

    private RedisTemplate<String, Object> redisTemplate;

    public CustomCache(RedisTemplate redisTemplate) {
        // 使用StringRedisSerializer做序列化
        // redisTemplate.setValueSerializer(new StringRedisSerializer());
        this.redisTemplate = redisTemplate;
    }

    /**
     * 缓存的key名称获取为shiro:cache:account
     *
     * @param key
     * @return java.lang.String
     * @author Wang926454
     * @date 2018/9/4 18:33
     */
    private String getKey(Object key) {
        return RedisConstant.PREFIX_SHIRO_CACHE + JwtUtil.getClaim(key.toString(), JwtConstant.ACCOUNT);
    }

    /**
     * 获取缓存
     */
    @Override
    public Object get(Object key) throws CacheException {
        return redisTemplate.opsForValue().get(this.getKey(key));
    }

    /**
     * 保存缓存
     */
    @Override
    public Object put(Object key, Object value) throws CacheException {
        // 读取配置文件,获取Redis的Shiro缓存过期时间
        // PropertiesUtil.readProperties("config.properties");
        // String shiroCacheExpireTime =
        // PropertiesUtil.getProperty("shiroCacheExpireTime");
        // 设置Redis的Shiro缓存
        try {
            redisTemplate.opsForValue().set(this.getKey(key), value, Integer.parseInt(shiroCacheExpireTime), TimeUnit.SECONDS);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 移除缓存
     */
    @Override
    public Object remove(Object key) throws CacheException {
        redisTemplate.delete(this.getKey(key));
        return null;
    }

    /**
     * 清空所有缓存
     */
    @Override
    public void clear() throws CacheException {
        // TODO Auto-generated method stub

    }

    /**
     * 缓存的个数
     */
    @Override
    public Set<K> keys() {
        // TODO Auto-generated method stub
        return null;
    }

    /**
     * 获取所有的key
     */
    @Override
    public int size() {
        // TODO Auto-generated method stub
        return 0;
    }

    /**
     * 获取所有的value
     */
    @Override
    public Collection<V> values() {
        // TODO Auto-generated method stub
        return null;
    }

    /*
     * @Override public void clear() throws CacheException {
     * redis.getJedis().flushDB(); }
     */

    /*
     * @Override public int size() { Long size = JedisUtil.getJedis().dbSize();
     * return size.intValue(); }
     */

    /*
     * @Override public Set keys() { Set<byte[]> keys =
     * JedisUtil.getJedis().keys(new String("*").getBytes()); Set<Object> set = new
     * HashSet<Object>(); for (byte[] bs : keys) {
     * set.add(SerializableUtil.unserializable(bs)); } return set; }
     */

    /*
     * @Override public Collection values() { Set keys = this.keys(); List<Object>
     * values = new ArrayList<Object>(); for (Object key : keys) {
     * values.add(JedisUtil.getObject(this.getKey(key))); } return values; }
     */
}

6-2. 重写Shiro缓存管理器

/**
 * 重写Shiro缓存管理器
 * 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
 *
 * @author 丶doufu
 * @date 2019/08/03
 */
public class CustomCacheManager implements CacheManager {

    private RedisTemplate<String, Object> redisTemplate;

    public CustomCacheManager(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public <K, V> Cache<K, V> getCache(String s) throws CacheException {
        return new CustomCache<K, V>(redisTemplate);
    }
}

七、自定义异常

为方便返回统一Json提示,我们就需要对shiro的异常信息进行重写,代码如下:

/**
 * 异常控制处理器
 * 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
 *
 * @author 丶doufu
 * @date 2019/08/03
 */
@RestControllerAdvice
public class ExceptionAdvice {

    /**
     * 捕捉所有Shiro异常
     */
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(ShiroException.class)
    public Result handle401(ShiroException e) {
        return new Result(ResultCode.UNLAWFUL, "无权访问(Unauthorized):" + e.getMessage());
    }

    /**
     * 单独捕捉Shiro(UnauthorizedException)异常 该异常为访问有权限管控的请求而该用户没有所需权限所抛出的异常
     */
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(UnauthorizedException.class)
    public Result handle401(UnauthorizedException e) {
        Result result = new Result();
        return new Result(ResultCode.UNLAWFUL, "无权访问(Unauthorized):当前Subject没有此请求所需权限(" + e.getMessage() + ")");
    }

    /**
     * 单独捕捉Shiro(UnauthenticatedException)异常
     * 该异常为以游客身份访问有权限管控的请求无法对匿名主体进行授权,而授权失败所抛出的异常
     */
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(UnauthenticatedException.class)
    public Result handle401(UnauthenticatedException e) {
        return new Result(ResultCode.UNLAWFUL, "无权访问(Unauthorized):当前Subject是匿名Subject,请先登录(This subject is anonymous.)");
    }

    /**
     * 捕捉校验异常(BindException)
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(BindException.class)
    public Result validException(BindException e) {
        List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
        Map<String, Object> error = this.getValidError(fieldErrors);
        return new Result(ResultCode.ERROR, error.get("errorMsg").toString(), error.get("errorList"));
    }

    /**
     * 捕捉校验异常(MethodArgumentNotValidException)
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result validException(MethodArgumentNotValidException e) {
        List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
        Map<String, Object> error = this.getValidError(fieldErrors);
        return new Result(ResultCode.ERROR, error.get("errorMsg").toString(), error.get("errorList"));
    }

    /**
     * 捕捉404异常
     */
    @ResponseStatus(HttpStatus.NOT_FOUND)
    @ExceptionHandler(NoHandlerFoundException.class)
    public Result handle(NoHandlerFoundException e) {
        return new Result(ResultCode.NOT_FOUND, e.getMessage());
    }

    /**
     * 捕捉其他所有异常
     */
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(Exception.class)
    public Result globalException(HttpServletRequest request, Throwable ex) {
        return new Result(ResultCode.ERROR, ex.toString() + ": " + ex.getMessage());
    }

    /**
     * 捕捉其他所有自定义异常
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(CustomException.class)
    public Result handle(CustomException e) {
        return new Result(ResultCode.ERROR, e.getMessage());
    }

    /**
     * 获取状态码
     */
    private HttpStatus getStatus(HttpServletRequest request) {
        Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code");
        if (statusCode == null) {
            return HttpStatus.INTERNAL_SERVER_ERROR;
        }
        return HttpStatus.valueOf(statusCode);
    }

    /**
     * 获取校验错误信息
     */
    private Map<String, Object> getValidError(List<FieldError> fieldErrors) {
        Map<String, Object> map = new HashMap<String, Object>(16);
        List<String> errorList = new ArrayList<String>();
        StringBuffer errorMsg = new StringBuffer("校验异常(ValidException):");
        for (FieldError error : fieldErrors) {
            errorList.add(error.getField() + "-" + error.getDefaultMessage());
            errorMsg.append(error.getField() + "-" + error.getDefaultMessage() + ".");
        }
        map.put("errorList", errorList);
        map.put("errorMsg", errorMsg);
        return map;
    }
}

八、获取当前登录用户

由于在上面的JwtFilter中我们已经把token提交给了shiro,因此直接从Subject中获取即可:

/**
 * 获取当前登录用户
 * 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
 *
 * @author 丶doufu
 * @date 2019/8/10
 */
@RequestMapping("/current")
public Result current() {
    try {
        User user = new User();
        Subject subject = SecurityUtils.getSubject();
        if (subject != null) {
            String token = (String) subject.getPrincipal();
            if (StringUtils.isNotBlank(token)) {
                String account = JwtUtil.getClaim(token, JwtConstant.ACCOUNT);
                if (StringUtils.isNotBlank(account)) {
                    user = testService.findUserByAccount(account);
                }
            }
        }
        return new Result(ResultCode.SUCCESS, "success.", user);
    } catch (Exception e) {
        e.printStackTrace();
        return new Result(ResultCode.ERROR, e.getMessage());
    }
}

调用如下:
%E5%BD%93%E5%89%8D%E7%99%BB%E5%BD%95%E7%94%A8%E6%88%B7.png

九、效果演示

主要的代码基本到此就已经结束,接下来给大家看看最后的集成效果。

  1. 首先我们正常编写一个接口,代码如下:

    /**
     * test
     * 转载请注明出处,更多技术文章欢迎大家访问我的个人博客站点:https://www.doufuplus.com
     *
     * @author 丶doufu
     * @date 2019/8/10
     */
    @RequestMapping("/test")
    public Result test() {
        return new Result(ResultCode.SUCCESS, "Hello SHIRO JWT!");
    }
    
  2. 使用postman直接接口访问,提示token cannot be empty.
    直接访问

  3.  携带上文登录接口中生成的token访问:携带token
  4. 由于我们是使用doufuplus这个账号登录生成的token,修改掉账号如下:
    %E6%95%B0%E6%8D%AE%E5%BA%93%E8%B4%A6%E5%8F%B7.png

  继续访问效果如下:
账号访问测试

至于其它的异常情况各位可以自行测试,这里就不再做过多说明。

十、 At Last

项目源码:GitHub (注意选择分支:shiro-jwt)


转载自该文章的个人博客(比原文增加了一个时序图):https://blog.csdn.net/stilll123456/article/details/88370355

原文原博主已重新整理到如下个人博客:

SpringBoot整合shiro+jwt+redis - 无状态token登录(一)总览篇
SpringBoot整合shiro+jwt+redis - 无状态token登录(二)授权篇
SpringBoot整合shiro+jwt+redis - 无状态token登录(三)鉴权篇

猜你喜欢

转载自blog.csdn.net/HD243608836/article/details/115860131