手把手教你shiro+jwt整合,2021终极版。

手把手教你整合shiro+jwt,2021终极版。

2021年发布shiro1.8带来了质的飞跃,对于本文的需求来说,最利好的包括两点:一是增加了对SpringBoot自动装配机制的支持;二是增加了BearerHttpAuthenticationFilter这个默认过滤器,从而让Jwt的整合获得了原生级的适配性。以上两项特性大大精简了我们的配置工作,且让当前网络上所有的教程都落后于时代。(包括官网和英文网络,搜到的教程基本都是旧版本的配置。)

  • 首先介绍一下自己开发的集成性RESTful框架KRest,功能即为整合了常用的shiro+jwt+通信加密模块,提供了一套极为简便易用的一体化配置。该框架的设计初衷就是帮助大家从繁琐的构建工作中解脱出来,让大家再也不用再花费太多时间来跟着这篇帖子一点点学原理。

  • 项目发布在gitee上 https://gitee.com/ckw1988/krest ,源码同时也发布到了maven中央库

    <dependency>
       <groupId>com.chenkaiwei.krest</groupId>
       <artifactId>krest-core</artifactId>
        <version>${最新版本号}</version>
    </dependency>
    

    使用以上依赖即可直接使用

如果您依然打算自己亲手完成一套shiro+jwt的配置,那么请继续往下看下去。本文在介绍配置时会多介绍一些shiro和jwt的机制原理,所以此贴同时也是一篇极好的理论教程。

话不多说,开搞。

配置文件

首先在pom里配上shiro1.8

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

然后是配置文件,如今在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。
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")和
factoryBean.setGlobalFilters(Arrays.asList("noSessionCreation"))。前者配置了BearerHttpAuthenticationFilter过滤器,让除了不验证规则("anon")以外的请求都经BearerHttpAuthenticationFilter过滤器处理,该过滤器的功能是自动解析请求头信息中的Authorization字段,并将其所携带的jwt token内容包装成一个BearerToken对象,以供后续使用;后者是个更为强大的过滤器NoSessionCreationFilter,一旦配上,不再存储任何用户信息,彻底成为一个真正的no-session服务。

有兴趣研究shiro的也可以看看其他shiro提供的默认过滤器,https://shiro.apache.org/web.html 介绍得很全面。这部分功能在文档里写的很清楚,就不再赘述,毕竟本文的主旨是配jwt。

如今的config部分只需要配置这么多,旧方案里那一大堆东西都不再需要了。此后你自定义的realm只需在类定义时加上@Component标签,即可由shiro自动装配使用(赞美SpringbBoot)。至于这个authorizer的bean,粗略研究了一下自动配置策略里是有的,但是为啥没有自动装配,等有空再研究了。反正自己配一下也费不了几秒钟的工夫。

身份验证。

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

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

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

我们先看处理token验证的TokenValidateAndAuthorizingRealm,此时客户端已经获得了初始的Jwt Token:

 @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脚本。

  2. 然后实现realm的代码,覆盖getAuthenticationTokenClass方法,shiro的默认机制是通过token的类型来确认是否由当前realm来处理当前收到的登录请求,本类中令该方法返回BearerToken.class即可。该对象由authcBearer filter取出请求头信息中携带的Authorization信息自动封装。由此shiro就会将authcBearer filter中发起的login操作交给该realm处理。

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

  4. 配置一个CredentialsMatcher。该对象才是真正处理验证登陆的步骤,我将其用匿名类创建在realm的构造器里,语法很好懂,看源码即可。

    同时,该步骤中还用到了工具类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验证框架是

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

    配到pom里去。

  5. 至此,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返回给客户端,从而节约一些服务器资源。

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

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

接下来介绍处理用户名密码登陆的UsernamePasswordRealm。之所以这个后介绍,是因为这个环节其实是个可选环节。获得初始jwt token的方式多种多样,可以是用户名密码登陆,可以是手机+验证码登陆,可以是第三方平台登录,可以是你自定义的随便什么方式的登录,甚至可以是通过其他服务登录已经获得了jwt token后再拿到本服务上来使用。

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

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

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

为了进一步介绍shiro的原理机制,这里选用shiro原生的用户名密码登陆的realm来演示登陆步骤的验证方式。

  1. 首先这种登录方式需要显示调用,毕竟用户名和密码不像jwt,怎么传没啥特别严格的规范,不方便自动处理。参考语法如下,定义在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。因为步骤1的login方法中传入的token为UsernamePasswordToken类型,所以该操作会被shiro分配给本realm来处理。(jwt的"登录"其实也有这个步骤,不过是shiro替你做了,源码在AuthenticatingFilter.executeLogin中,感兴趣的可以看看)。

  4. doGetAuthenticationInfo中返回值按照new SimpleAuthenticationInfo(jwtUser, passwordFromDB, ByteSource.Util.bytes(salt),getName());来配,第一个参数依然是登陆成功后的用户信息,第三个是密码的盐。

  5. 密码验证策略是md5哈希2次加盐,因为这个验证规则shiro里有现成的实现,就不用自己写了,直接用HashedCredentialsMatcher即可。代码一样在构造器里。

  6. 这部分因为自由度很高,你完全不走shiro的realm也可以,和jwt的realm一样自定义matcher也可以,没啥必要非得使用shiro自带的密码验证规则,平白增加学习成本,所以介绍得粗略些。懒得学也可以像jwt的realm那样自定义matcher,用自己熟悉的加密策略和加密工具实现,反正前面jwt realm里matcher的实现就是现成的参考。

  7. 再次提醒一下不要遗漏@Component注解。

权限管理

首先你的用户-权限的数据模型要符合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()即可获取;权限通过对照表,由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);//仅是示例,按需求定义
    }
}

posted @   ckw1988  阅读(3907)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 25岁的心里话
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 一起来玩mcp_server_sqlite,让AI帮你做增删改查!!
· 零经验选手,Compose 一天开发一款小游戏!
点击右上角即可分享
微信分享提示