【SpringBoot】2021终极版shiro+jwt整合策略,包含shiro1.5+新特性,极简配置,全网独家。

2021终极版shiro+jwt整合策略,包含shiro1.5+新特性,极简配置,全网独家。

前言:shiro1.4的配置之繁琐业内闻名,其实它自1.5之后就有了不小的改进,能够大大精简我们前期的整合配置工作。但离奇的是1.5版至今也好几年了,网上依然铺天盖地都是1.4版的繁琐教程,所以干脆就由我来填上这一小块空白,回馈一下开源社区。

另:你也可以直接使用本人开发的框架KRest来实现两者的集成使用,只需完成一些最必要的配置即可在您项目内添加一套完整的RESTful服务的核心支持功能能,简易配置无感使用。
目前包含的功能是jwt认证、通信加密、接口权限控制。
项目源码在gitee上 https://gitee.com/ckw1988/krest ,也发布到了maven中央库,使用非常方便。
如果您出于对知识的热忱和追求依然打算自己亲手完成一套shiro+jwt的配置,那么请继续往下看下去。

本文在介绍配置时会深入讲解一些相关shiro和jwt的机制原理,所以此贴同时也是一篇shiro机制原理的介绍教程。

下面正式开始。

示例源码

源码地址:https://gitee.com/ckw1988/shiro-jwt-integration
原文地址:https://blog.csdn.net/ckw1988/article/details/123691453

并包含一个调试用的postman脚本,强烈建议下载下来跑通了再来看教程,心里比较踏实。

配置文件

首先在pom里配上shiro1.8(本文撰写时的最新版)

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-web-starter</artifactId>
    <version>1.8.0</version>
</dependency>

然后是配置文件,如今在的1.8也换上了springboot自动装配机制,config中只需配置两个bean。代码如下:

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
	ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
	
	/*
	* filter配置规则参考官网
	* http://shiro.apache.org/web.html#urls-
	* 默认过滤器对照表
	* https://shiro.apache.org/web.html#default-filters
	*/
	
	Map<String, String> filterRuleMap = new HashMap<>();
	
	filterRuleMap.put("/static/*", "anon");
	filterRuleMap.put("/error", "anon");
	filterRuleMap.put("/register", "anon");
	filterRuleMap.put("/login", "anon");
	//↑配置不参与验证的映射路径。
	
	// 关键:配置jwt验证过滤器。
	//↓ 此处即为shiro1.8新增的默认过滤器:authcBearer-BearerHttpAuthenticationFilter。jwt验证的很多操作都由该filter自动完成,以致我们只需理解其机制而无需亲手实现。
	filterRuleMap.put("/**", "authcBearer");
	//↑ 如果有其他过滤法则配在/**上,则在第二个参数的字符串里使用逗号间隔。
	
	factoryBean.setGlobalFilters(Arrays.asList("noSessionCreation"));
	//↑ 关键:全局配置NoSessionCreationFilter,把整个项目切换成无状态服务。
	
	factoryBean.setSecurityManager(securityManager);
	factoryBean.setFilterChainDefinitionMap(filterRuleMap);
	return factoryBean;
}

@Bean
protected Authorizer authorizer() {
	ModularRealmAuthorizer authorizer = new ModularRealmAuthorizer();
	return authorizer;
}

关键代码的功能和含义看我注释就行。这里重点解释两句:

  • filterRuleMap.put(“/**”, “authcBearer”):
    这条语句配置了BearerHttpAuthenticationFilter过滤器,是JWT验证机制的核心。功能是自动解析请求头信息中的Authorization字段,并将其所携带的jwt token内容包装成一个BearerToken对象,并调用login方法进入realm进行身份验证。

    在shiro旧版本的时代,最靠谱的方案就是继承HttpAuthenticationFilter自行实现一个过滤器来处理jwt功能,如今这个BearerHttpAuthenticationFilter即是该功能的官方实现。我们只需学会配置即可。

  • factoryBean.setGlobalFilters(Arrays.asList(“noSessionCreation”))。
    这里配置了个特别强大的过滤器NoSessionCreationFilter。shiro默认是保存状态的服务,所以必须配上这个filter,才能将整个shiro系统转换为一个真正的no-session服务。所以说,假如有啥教程缺乏了这一步骤,尽管功能一样能跑,但他们所说的no-session并不是真正的no-session。

如今的config部分只需要配置这么多,旧方案里那一大堆东西都不再需要了。此后你自定义的realm只需在类定义时加上@Component标签,即可由shiro自动装配使用(赞美SpringBoot)。

身份验证。

因为我们整个服务已经变成no-session状态,所以事实上对shiro来说整个系统中已经不存在"已登录用户"这个概念了,这就意味着每一次独立的请求事实上都需要一个身份验证过程,这种身份验证行为在shiro里都被称为"登录(Login)"。

整合后的流程为:首次登陆时用户提交用户名和密码,验证通过后服务器生成一个初始的Jwt Token返回给客户端。此后客户端在任何请求时都把Jwt Token带上,服务端如果验证通过后即视为当次身份验证通过(或者说以token的方式登陆成功)。这个流程也即jwt token的官方标准使用方法。

既然有两种登陆方式,则需要两个realm,我们需要一个UsernamePasswordRealm来处理用户名和密码登录;一个TokenValidateAndAuthorizingRealm,处理token验证方式的"登录"。

UsernamePasswordRealm

  1. 首先,这种登录方式无论token的封装和login的调用都需要由用户自行完成,所以也特别适合演示shiro的完整处理流程。参考语法如下,定义在controller中

     /**
      * 登陆
      */
     @PostMapping("/login")
     public Map login(@RequestBody User userInput) throws Exception {
    
         String username = userInput.getUsername();
         String password = userInput.getPassword();
    
         Assert.notNull(username, "username不能为空");
         Assert.notNull(password, "password不能为空");
    
         UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password);
         Subject subject = SecurityUtils.getSubject();
         subject.login(usernamePasswordToken);//显示调用登录方法
    
         //生成返回token
         Map<String,String> res=new HashMap<>();
         JwtUser jwtUser = (JwtUser) SecurityUtils.getSubject().getPrincipal();
    
         res.put("token",JwtUtil.createJwtTokenByUser(jwtUser));
         res.put("result","login success or other result message");
         return res;
     }
    
  2. subject.login(usernamePasswordToken)的操作,事实上是就进入了由realm处理身份验证的环节。我们先看代码

     //Username Password Realm,用户名密码登陆专用Realm
     @Slf4j
     @Component
     public class UsernamePasswordRealm extends AuthenticatingRealm {
         @Autowired
         private UserService userService;
     
         /*构造器里配置Matcher*/
         public UsernamePasswordRealm() {
             super();
             HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
             hashedCredentialsMatcher.setHashAlgorithmName("md5");
             hashedCredentialsMatcher.setHashIterations(2);//密码保存策略一致,2次md5加密
             this.setCredentialsMatcher(hashedCredentialsMatcher);
         }
     
         /**
          * 通过该方法来判断是否由本realm来处理login请求
          *
          * 调用{@code doGetAuthenticationInfo(AuthenticationToken)}之前会shiro会调用{@code supper.supports(AuthenticationToken)}
          * 来判断该realm是否支持对应类型的AuthenticationToken,如果相匹配则会走此realm
          *
          * @return
          */
         @Override
         public Class getAuthenticationTokenClass() {
             log.info("getAuthenticationTokenClass");
             return UsernamePasswordToken.class;
         }
     
         @Override
         public boolean supports(AuthenticationToken token) {
             //继承但啥都不做就为了打印一下info
             boolean res = super.supports(token);//会调用↑getAuthenticationTokenClass来判断
             log.debug("[UsernamePasswordRealm is supports]" + res);
             return res;
         }
     
         /**
          * 用户名和密码验证,login接口专用。
          */
         @Override
         protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) {
     
             UsernamePasswordToken usernamePasswordToken=(UsernamePasswordToken) token;
     
             User userFromDB=userService.queryUserByName(usernamePasswordToken.getUsername());
     
             String passwordFromDB = userFromDB.getPassword();
             String salt = userFromDB.getSalt();
     
             //在使用jwt访问时,shiro中能拿到的用户信息只能是token中携带的jwtUser,所以此处保持统一。
             JwtUser jwtUser=new JwtUser(userFromDB.getUsername(),userFromDB.getRoles());
             SimpleAuthenticationInfo res = new SimpleAuthenticationInfo(jwtUser, passwordFromDB, ByteSource.Util.bytes(salt),
                     getName());
             return res;
         }
    }
    
  3. 首先是覆盖getAuthenticationTokenClass方法,此时设定返回值为UsernamePasswordToken.class。shiro的机制是根据login方法中传入的token类型来分配realm,步骤1中是UsernamePasswordToken,所以分配给本realm来处理。

       @Override
       public Class getAuthenticationTokenClass() {
           log.info("getAuthenticationTokenClass");
           return UsernamePasswordToken.class;
       }
    
  4. doGetAuthenticationInfo,顾名思义,是配置验证成功后的用户信息,同时也是为后续的验证提供素材的步骤。将其返回值按照new SimpleAuthenticationInfo(jwtUser, passwordFromDB, ByteSource.Util.bytes(salt),getName());来配,第一个参数是登陆成功后的用户信息,第二个是来自数据库经过处理的目标密码,第三个是密码的盐。

    
        /**
         * 用户名和密码验证,login接口专用。
         */
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) {
    
            UsernamePasswordToken usernamePasswordToken=(UsernamePasswordToken) token;
    
            User userFromDB=userService.queryUserByName(usernamePasswordToken.getUsername());
    
            String passwordFromDB = userFromDB.getPassword();
            String salt = userFromDB.getSalt();
    
            //由于在使用jwt访问时,shiro中能拿到的用户信息只能是token中携带的jwtUser,所以此处建议保持统一。
            JwtUser jwtUser=new JwtUser(userFromDB.getUsername(),userFromDB.getRoles());
            SimpleAuthenticationInfo res = new SimpleAuthenticationInfo(jwtUser, passwordFromDB, ByteSource.Util.bytes(salt),
                    getName());
            return res;
        }
    }
    
  5. 密码验证策略是经典的md5哈希2次加盐,因为这个验证规则shiro里有现成的实现,就不用自己写了,直接用HashedCredentialsMatcher即可。这部分其实更推荐自定义自己的matcher,用自己熟悉的加密策略和加密工具自由地实现,学习成本更低,灵活度更高,也更便于和注册方法中的加密策略保持统一(注册的步骤shiro不会接管)。这里出于教学目的选择展示他自带的用法,自定义matcher的示例参考后面jwt的realm。

        /*构造器里配置Matcher*/
        public UsernamePasswordRealm() {
            super();
            HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
            hashedCredentialsMatcher.setHashAlgorithmName("md5");
            hashedCredentialsMatcher.setHashIterations(2);//密码保存策略一致,2次md5加密
            this.setCredentialsMatcher(hashedCredentialsMatcher);
        }
    
  6. 再次提醒一下不要遗漏@Component注解。

进阶扩展

事实上这个UsernamePasswordRealm是个可选环节。获得初始jwt token的方式多种多样,可以是用户名密码登陆,可以是手机+验证码登陆,可以是第三方平台登录,甚至可以是通过其他服务登录已经获得了jwt token后再拿到本服务上来使用。

所以事实上最简单做法是,只要你认为某个登陆请求已经完成了登陆步骤,只需要在返回值中带上一个新token

   ……
   res.put("token",JwtUtil.createJwtTokenByUser(jwtUser));

即可视为登陆成功。之后的其他请求自然会进入你在TokenValidateAndAuthorizingRealm中定义好的验证流程来处理。

TokenValidateAndAuthorizingRealm

 @Slf4j
 @Component
 public class TokenValidateAndAuthorizingRealm extends AuthorizingRealm {
 
     //权限管理部分的代码先行略过
     //......
 
     public TokenValidateAndAuthorizingRealm() {
         //CredentialsMatcher,自定义匹配策略(即验证jwt token的策略)
         super(new CredentialsMatcher() {
             @Override
             public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
                 log.info("doCredentialsMatch token合法性验证");
                 BearerToken bearerToken = (BearerToken) authenticationToken;
                 String bearerTokenString = bearerToken.getToken();
                 log.debug(bearerTokenString);
                 boolean verified = JwtUtil.verifyTokenOfUser(bearerTokenString);
 
                 return verified;
             }
         });
     }
 
     @Override
     public String getName() {
         return "TokenValidateAndAuthorizingRealm";
     }
 
     @Override
     public Class getAuthenticationTokenClass() {
         //设置由本realm处理的token类型。BearerToken是在filter里自动装配的。
         return BearerToken.class;
     }
 
     @Override
     public boolean supports(AuthenticationToken token) {
         boolean res=super.supports(token);
         log.debug("[TokenValidateRealm is supports]" + res);
         return res;
     }
 
 
     @Override//装配用户信息,供Matcher调用
     public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException, TokenExpiredException {
         log.debug("doGetAuthenticationInfo 将token装载成用户信息");
 
         BearerToken bearerToken = (BearerToken) authenticationToken;
         String bearerTokenString = bearerToken.getToken();
 
         JwtUser jwtUser = JwtUtil.recreateUserFromToken(bearerTokenString);//只带着用户名和roles
 
         SimpleAuthenticationInfo res = new SimpleAuthenticationInfo(jwtUser, bearerTokenString, this.getName());
         /*Constructor that takes in an account's identifying principal(s) and its corresponding credentials that verify the principals.*/
 //        这个返回值是造Subject用的,返回值供createSubject使用
         return res;
     }

该realm的功能除了身份验证还包含权限控制。为免干扰理解先行省略权限部分的代码,先说身份验证。

  1. 首先,让客户端在请求中带上jwt token。按照jwt的通用规范,具体的做法是客户端将token字符串加上"Bearer "前缀后放在头信息的Authorization字段里。该信息会在authcBearer过滤器中自动解析,并将其所携带的jwt token内容包装成一个BearerToken对象。这一部分可参考实例源码中的postman脚本。

    完成后的效果类似下表

    KEYVALUE
    AuthorizationBearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJhZG1pbiJdLCJleHAiOjE2NDY3OTcyMjEsInVzZXJuYW1lIjoiemhhbmczIn0.HroVIdxf5qmpjWJlOs0QGW7OtaTcjirD9aMViK4oDdI

    注意:Bearer和令牌字符串之间有且仅有一个半角空格。显示时可能会自动换行但不要被迷惑,复制粘贴一下就知道了。

  2. 不同于用户名密码的登录方式,包装token和login调用的操作,已经由filter接管,所以我们不需要自己来写这部分代码,直接进入realm的环节。

  3. 然后实现realm的代码,依然覆盖getAuthenticationTokenClass方法,本类中令该方法返回BearerToken.class(即由authcBearer filter自动封装而成的token类型)。由此shiro就就会将authcBearer filter中发起的“登录”请求交给该realm处理。

     @Override
     public Class getAuthenticationTokenClass() {
         //设置由本realm处理的token类型。BearerToken是在filter里自动装配的。
         return BearerToken.class;
     }
    

    注意区分两种token的概念,jwt token是一串字符串,用于在客户端和服务端常规通信时的身份保持。而shiro中的token是一个java bean,它是对用户身份信息的一种封装,用于服务器内部、在shiro框架中包装和传递待验证的用户信息:在用户名密码登陆时它是封装了用户名密码的UsernamePasswordToken,在jwt验证时它是封装了jwt token字符串的BearerToken。

  4. 接下来是实现doGetAuthenticationInfo方法,该方法用于装配登陆成功后的用户信息(返回值的第一个参数)和供验证的身份信息(返回值的第二个参数),第三个参数大约是用于区分本次登陆是由哪个realm通过的,不太重要,带上即可。

       @Override//装配用户信息,供Matcher调用
    public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException, TokenExpiredException {
        log.debug("doGetAuthenticationInfo 将token装载成用户信息");
    
        BearerToken bearerToken = (BearerToken) authenticationToken;
        String bearerTokenString = bearerToken.getToken();
    
        JwtUser jwtUser = JwtUtil.recreateUserFromToken(bearerTokenString);//只带着用户名和roles
    
        SimpleAuthenticationInfo res = new SimpleAuthenticationInfo(jwtUser, bearerTokenString, this.getName());
        /*Constructor that takes in an account's identifying principal(s) and its corresponding credentials that verify the principals.*/
    	 //  这个返回值是造Subject用的,返回值供createSubject使用
        return res;
    }
    
  5. 自定义且实现一个CredentialsMatcher,用以处理验证jwt token的登陆方式,核心中的核心。我将其用匿名类创建在realm的构造器里。两个入参来自上一步骤,语法很好懂,看源码即可。

    public TokenValidateAndAuthorizingRealm() {
        //CredentialsMatcher,自定义匹配策略(即验证jwt token的策略)
        super(new CredentialsMatcher() {
            @Override
            public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
                log.info("doCredentialsMatch token合法性验证");
                BearerToken bearerToken = (BearerToken) authenticationToken;
                String bearerTokenString = bearerToken.getToken();
                log.debug(bearerTokenString);
                boolean verified = JwtUtil.verifyTokenOfUser(bearerTokenString);
    
                return verified;
            }
        });
    }
    
  6. 同时,该步骤中还用到了自己封装的工具类JwtUtil,代码如下:

      @Slf4j
      public class JwtUtil {
      
      
          //指定一个token过期时间(毫秒)
          private static final long EXPIRE_TIME = 20 * 60 * 1000;  //20分钟
          private static final String JWT_TOKEN_SECRET_KEY = "yourTokenKey";
          //↑ 记得换成你自己的秘钥
      
          public static String createJwtTokenByUser(JwtUser user) {
      
              String secret = JWT_TOKEN_SECRET_KEY;
      
              Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
              Algorithm algorithm = Algorithm.HMAC256(secret);    //使用密钥进行哈希
              // 附带username信息的token
              return JWT.create()
                      .withClaim("username", user.getUsername())
                      .withClaim("roles", user.getRoles())
      //                .withClaim("permissions",permissionService.getPermissionsByUser(user))
                      .withExpiresAt(date)  //过期时间
                      .sign(algorithm);     //签名算法
              //r-p的映射在服务端运行时做,不放进token中
          }
      
      
          /**
           * 校验token是否正确
           */
          public static boolean verifyTokenOfUser(String token) throws TokenExpiredException {//user要从sercurityManager拿,确保用户用的是自己的token
              log.info("verifyTokenOfUser");
              String secret = JWT_TOKEN_SECRET_KEY;//
      
              //根据密钥生成JWT效验器
              Algorithm algorithm = Algorithm.HMAC256(secret);
              JWTVerifier verifier = JWT.require(algorithm)
                      .withClaim("username", getUsername(token))//从不加密的消息体中取出username
                      .build();
              //生成的token会有roles的Claim,这里不加不知道行不行。
              // 一个是直接从客户端传来的token,一个是根据盐和用户名等信息生成secret后再生成的token
              DecodedJWT jwt = verifier.verify(token);
              //能走到这里
              return true;
      
          }
      
          /**
           * 在token中获取到username信息
           */
          public static String getUsername(String token) {
              try {
                  DecodedJWT jwt = JWT.decode(token);
                  return jwt.getClaim("username").asString();
              } catch (JWTDecodeException e) {
                  return null;
              }
          }
      
          public static JwtUser recreateUserFromToken(String token) {
              JwtUser user = new JwtUser();
              DecodedJWT jwt = JWT.decode(token);
      
              user.setUsername(jwt.getClaim("username").asString());
              user.setRoles(jwt.getClaim("roles").asList(String.class));
              //r-p映射在运行时去取
              return user;
          }
      
          /**
           * 判断是否过期
           */
          public static boolean isExpire(String token) {
              DecodedJWT jwt = JWT.decode(token);
              return jwt.getExpiresAt().getTime() < System.currentTimeMillis();
          }
      }
    
    

    因为封装比较简单,看看源码和注解即可。对jwt验证规则有任何自定义的要求都在这里实现。
    该类中所用到的JWT验证框架是

      <dependency>
          <groupId>com.auth0</groupId>
          <artifactId>java-jwt</artifactId>
          <version>3.18.2</version>
      </dependency>
    

    配到pom里去。

  7. 至此,jwt验证部分的功能配置完毕。DemoController中的whoami方法是这部分的使用范例。

      @GetMapping("/whoami")
      public Map whoami(){
          JwtUser jwtUser = (JwtUser) SecurityUtils.getSubject().getPrincipal();
    
          Map<String,String> res=new HashMap<>();
          res.put("result","you are "+jwtUser);
          res.put("token",JwtUtil.createJwtTokenByUser(jwtUser));
    
          return res;
      }
    

    JwtUser是携带在JwtToken中的用户信息,因为no-session服务不再储存用户信息,所以用户信息就得放在jwtToken中携带,这也是jwt的规范之一。同时这个jwtUser也即是在先前第3步骤的返回值第一个参数中配置进去的用户信息,你可以根据需要自行设定这个对象,步骤3中传进去啥,getSubject中取出来的就是啥。

    注意返回值中还需要加上新生成的Jwt token,因为token有过期时间,所以一次成功的带jwt的请求成功返回时,还应当把新的token带给客户端,供它下次请求时使用。进阶的做法是仅在token即将过期时才生成新token返回给客户端,从而节约一些服务器资源。该功能在我自行封装的KRest框架中也已经实现,欢迎选用。

  8. 客户端在拿到返回信息后,将token中的内容取代步骤1中的旧token,下次请求时用同样的规则带上即可。如果用了即将过期时才刷新token的机制且还没到token刷新时间,则继续使用旧token即可。如此新token连续不断地替换掉旧token,用户的登录状态就能视为一直保持。

  9. 当然如果两次请求的间隔时间超过了token中预设的过期时间(即上面JWTUtil源码中的EXPIRE_TIME),则token验证会不通过,提示tokne过期,此时客户端应重新把页面跳转到用户名和密码的登录页要求用户重新登录。

权限管理

首先你的用户-权限的数据模型要符合RBAC规范,这个概念这里不再赘述。
因为服务端不存用户信息了,所以此时role、permission和这两级数据和user怎么关联就是一个问题,我这里决定的方案是,roles信息跟着user一起存在jwt token里,然后permissions和role的对应因为相对固定,所以在服务端维护一份对应表即可。
代码也是在TokenValidateAndAuthorizingRealm中,这里把权限相关部分贴一遍

    @Slf4j
    @Component
    public class TokenValidateAndAuthorizingRealm extends AuthorizingRealm {
    
        UserService userService;
    
        Map<String, Collection<String>> rolePermissionsMap;
    
        @Autowired
        public void setUserService(UserService userService){
            this.userService=userService;
            rolePermissionsMap= userService.getRolePermissionMap();
            //自动注入时查询一次存成变量,避免每次权限管理都去调用userService
        }
        ……//身份验证部分省略
    
        @Override//权限管理
        protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
            log.debug("doGetAuthorizationInfo 权限验证");
    
            JwtUser user = (JwtUser) SecurityUtils.getSubject().getPrincipal();
    
            SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
            simpleAuthorizationInfo.addRoles(user.getRoles());//roles跟着user走,放到token里。
    
            Set<String> stringPermissions = new HashSet<String>();
            for (String role : user.getRoles()) {
                stringPermissions.addAll(rolePermissionsMap.get(role));
            }
            simpleAuthorizationInfo.addStringPermissions(stringPermissions);
            return simpleAuthorizationInfo;
        }
    }

rolePermissionsMap顾名思义,就是所有角色-权限的对照表。这里配置一份后供以后每次用户有需要时调用,查出权限集合。

doGetAuthorizationInfo方法,本质是返回当前用户所拥有的角色和权限的集合,角色本身就存在token里,用user.getRoles()即可获取;权限通过对照表(rolePermissionsMap),由roles查询添加而来,代码应该都不难懂。

在controller中配一个这样的方法来试用该功能

    @GetMapping("/permissionDemo")
        @RequiresPermissions("pd")
        public Map permissionDemo(){
    
            Map<String,String> res=new HashMap<>();
            res.put("result","you have got the permission [pd]");
    
            JwtUser jwtUser = (JwtUser) SecurityUtils.getSubject().getPrincipal();
            res.put("token",JwtUtil.createJwtTokenByUser(jwtUser));
    
            return res;
        }

@RequiresPermissions(“pd”)表示拥有"pd"权限的用户才有访问当前方法的权限。

用postman脚本测试,zhang3(拥有admin角色以及pd权限)可以正常访问,li4(没有pd权限)则会返回异常。

异常返回

自行阅读GlobalExceptionController即可,与本帖主题关系不大的代码就不在这里专门说了。

   
@Slf4j
@RestControllerAdvice
public class GlobalExceptionController {

    // 身份验证错误
    @ExceptionHandler(AuthenticationException.class)
    public ResponseEntity authenticationExceptionHandler(AuthenticationException e) {
        log.error("AuthenticationException");
        log.error(e.getLocalizedMessage());

        Map<String,Object> body=new HashMap<String,Object>();
        body.put("status", HttpStatus.FORBIDDEN.value());
        body.put("message",e.getLocalizedMessage());
        body.put("exception",e.getClass().getName());
        body.put("error", HttpStatus.FORBIDDEN.getReasonPhrase());
        return new ResponseEntity(body, HttpStatus.FORBIDDEN);//仅是示例,按需求定义
    }

    //权限验证错误
    @ExceptionHandler(UnauthorizedException.class)
    public ResponseEntity unauthorizedExceptionHandler(UnauthorizedException e) {
        log.error("unauthorizedExceptionHandler");
        log.error(e.getLocalizedMessage());

        Map<String,Object> body=new HashMap<String,Object>();
        body.put("status", HttpStatus.UNAUTHORIZED.value());
        body.put("message",e.getLocalizedMessage());
        body.put("exception",e.getClass().getName());
        body.put("error", HttpStatus.UNAUTHORIZED.getReasonPhrase());
        return new ResponseEntity(body, HttpStatus.UNAUTHORIZED);//仅是示例,按需求定义
    }


    //对应路径不存在
    @ExceptionHandler(NoHandlerFoundException.class)
    public ResponseEntity noHandlerFoundExceptionHandler(NoHandlerFoundException e) {
        log.error("noHandlerFoundExceptionHandler");
        log.error(e.getLocalizedMessage());

        Map<String,Object> body=new HashMap<String,Object>();
        body.put("message",e.getLocalizedMessage());
        body.put("exception",e.getClass().getName());
        body.put("error", HttpStatus.NOT_FOUND.getReasonPhrase());
        return new ResponseEntity(body, HttpStatus.NOT_FOUND);//仅是示例,按需求定义
    }
    @ExceptionHandler(Exception.class)
    public ResponseEntity exceptionHandler(Exception e) {
        log.error("exceptionHandler");
        log.error(e.getLocalizedMessage());
        log.error(e.getStackTrace().toString());

        Map<String,Object> body=new HashMap<String,Object>();
        body.put("message",e.getLocalizedMessage());
        body.put("exception",e.getClass().getName());
        body.put("error", HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
        return new ResponseEntity(body, HttpStatus.INTERNAL_SERVER_ERROR);//仅是示例,按需求定义
    }
}

补充1:解决jwt验证出错时的异常丢失和跨域问题

这两个问题各自通过一个自定义filter来解决,所以放这里一起介绍。

1. jwt验证出错时的异常丢失

这个问题源自多realm时的异常机制:当某个realm出现异常时并不会直接一路抛上来,而是去验下一个realm,确认每个realm都异常或无法通过时,才统一返回个请求被拒,所以具体realm中抛出的异常的细节会丢失,在外面只收得到一个。
目前的解决办法是手动继承一下BearerHttpAuthenticationFilter,在请求被拒时补上一个异常抛出。有更好的方案也欢迎留言讨论。
(后续补充:按前面说的,用户名密码验证可以不走shiro的realm,那么整个shiro就不用配成多realm模式,单独一个realm用来验证jwt也许是个更好的选择,等我有空确认一下。)

@Slf4j
public class JwtFilter extends BearerHttpAuthenticationFilter {

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {

        boolean res=super.onAccessDenied(request, response);//jwt的登录在这里面
        log.info("onAccessDenied "+res);
        if (!res){
            throw new RuntimeException("token失效或异常,请重新登录");//jwt验证器的错误抛不上来,应该是shiro机制的不完善()
        }
//        jwt验证失败导致的登陆失败里,拿不到jwt验证失败的具体异常,因为要试过多个realmjwt的token错了还会去试其他realm,
//        导致他把具体异常截断了,这里只拿得到一个"试过所有realm但是都没登陆成功"的异常。
        return res;
    }
}

filter的配置与后面跨域的filter一起说,一样是通过自定义filter解决。

2.跨域问题

一样是增加一个自定义filter处理

@Slf4j
public class CorsFilter extends PathMatchingFilter {

    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        configHeaders(httpRequest, httpResponse);//options和其他方法共用
        if (httpRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {

            log.debug("收到一个OPTIONS请求--"+httpRequest.getRequestURI());
            httpResponse.setStatus(HttpStatus.NO_CONTENT.value());
            return false;
        }

        return super.preHandle(request, response);
    }


    private void configHeaders(HttpServletRequest request, HttpServletResponse response){
        //↓ 该部分均可按照自己需要自行订制,这里只是做个参考
        
        response.setHeader("Access-Control-Allow-Origin", "http://yourclientdomain:1111");//TODO 配置你自己允许的前端源
        response.setHeader("Access-Control-Allow-Methods", request.getMethod());
        response.setHeader("Access-Control-Allow-Credentials", "true");

        response.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
        //防止乱码,适用于传输JSON数据
        response.setHeader("Content-Type","application/json;charset=UTF-8");
    }
}
3.配置以上两个自定义filter

在ShiroConfig中增加相应配置,具体说明看后面的注释即可。

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SessionsSecurityManager securityManager) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();

        //配置自定义filter
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("cors",new CorsFilter());
        filterMap.put("jwt", new JwtFilter());
        factoryBean.setFilters(filterMap);
        
		……


        // jwt验证过滤器。
//        filterRuleMap.put("/**", "authcBearer");
        filterRuleMap.put("/**", "jwt");//用自定义的jwt代替authcBearer,前者是后者的子类

        factoryBean.setGlobalFilters(Arrays.asList("cors","noSessionCreation"));//将corsFilter配置成全局,注意不能放在上面jwt的位置。只有放在这里才能不受"anon"等其他过滤器的影响,是真正的全局。
        
        factoryBean.setSecurityManager(securityManager);
        factoryBean.setFilterChainDefinitionMap(filterRuleMap);
        return factoryBean;
    }

续签策略

本示例中采用了最简单的jwt续签机制,即在普通的业务请求中检查过期时间后直接发放新token,该策略安全性较低,如果在公司企业的项目中使用,建议参考另一篇拙作《“长短令牌三验证”的JWT续签策略》进一步完善续签机制。

  • 29
    点赞
  • 142
    收藏
    觉得还不错? 一键收藏
  • 63
    评论
ShiroJWT是两个不同的技术,可以结合使用来实现认证和授权功能。 Shiro是一个Java领域非常知名的认证和授权框架,用于替代JavaEE中的JAAS功能。相较于其他框架,Shiro设计简单,适用性广泛。任何JavaWeb项目都可以使用Shiro框架,包括非Spring框架的项目,如JFinal和Nutz。Shiro可以用于保存登录凭证、查询用户角色和权限,并通过注解来限制方法的访问权限。\[1\] JWT(JSON Web Token)是一种用于身份验证的技术。传统的HttpSession依赖于浏览器的Cookie存放SessionId,要求客户端必须是浏览器。而JWT的Token是纯字符串,可以被任何客户端使用,包括浏览器、APP、小程序和物联网设备。客户端在发起请求时,只需附带上Token即可,而无需特定的存储方式。例如,物联网设备可以使用SQLite存储Token数据。\[2\] 因此,结合ShiroJWT可以实现更灵活的认证和授权功能。Shiro负责处理用户登录、角色和权限的管理,而JWT负责生成和验证身份令牌。通过结合使用,可以实现跨平台、跨设备的身份验证和授权功能。 #### 引用[.reference_title] - *1* *2* *3* [ShiroJWT简介](https://blog.csdn.net/lad_z/article/details/129240771)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]
评论 63
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值