shiro-redis-jwt整合

playboy5566發表於2022-03-15

一、整合流程邏輯

20201031132733189.png

二、整合步驟

1. 匯入shiro-redis的starter包:還有jwt的工具包,以及為了簡化開發,我引入了hutool工具包。

<!--shiro-redis整合-->
        <dependency>
            <groupId>org.crazycake</groupId>
            <artifactId>shiro-redis-spring-boot-starter</artifactId>
             <version>3.2.1</version>
        </dependency>
        <!-- hutool工具類-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.3.3</version>
        </dependency>
        <!-- jwt -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

2. 編寫配置

  • 引入RedisSessionDAO和RedisCacheManager,實現將shiro許可權資料和會話資訊儲存到redis中,實現會話共享。
  • 重寫 shiro中的SessionManager和DefaultWebSecurityManager,同時在重寫的DefaultWebSecurityManager中關閉shiro自 帶的session,需要設定位false,這樣使用者將不能通過session方式登陸shiro。後面採用jwt憑證登陸。
  • 重寫 shiro的ShiroFilterChainDefinition 註冊自己的過濾器。我們將不再通過編碼方式攔截訪問路徑,而是所有路徑通過自己註冊的JwtFilter過濾器,然後判斷是否有jwt憑證,有則登陸,無則跳過,跳過之後,有shiro的許可權註解進行攔截,eg:@RequiredAuthentication,這樣控制許可權訪問。
@Configuration
public class ShiroConfig {
    @Autowired
    JwtFilter jwtFilter;
    /**
     * session域管理
     * @param redisSessionDAO
     * @return
     */
    @Bean
    public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) {
        DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();

        // inject redisSessionDAO
        sessionManager.setSessionDAO(redisSessionDAO);
        return sessionManager;
    }


    /**
     * 重寫shiro的安全管理容器,
     * @param accountRealm
     * @param sessionManager
     * @param redisCacheManager
     * @return
     */
    @Bean
    public SessionsSecurityManager securityManager(AccountRealm accountRealm, SessionManager sessionManager, RedisCacheManager redisCacheManager) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(accountRealm);

        //inject sessionManager
        securityManager.setSessionManager(sessionManager);

        // inject redisCacheManager
        securityManager.setCacheManager(redisCacheManager);
        return securityManager;
    }

    /**
     * 定義過濾器
     * @return
     */
    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition(){
        // 申請一個預設的過濾器鏈
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        Map<String,String> filterMap = new LinkedHashMap<>();

        //新增一個jwt過濾器到過濾器鏈中
        filterMap.put("/**","jwt");
        chainDefinition.addPathDefinitions(filterMap);
        return chainDefinition;
    }

    /**
     * 過濾器工廠業務
     * @param securityManager shiro中的安全管理
     * @param shiroFilterChainDefinition
     * @return
     */
    @Bean("shiroFilterFactoryBean")
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,
                                                                 ShiroFilterChainDefinition shiroFilterChainDefinition){
        /*shiro過濾器bean物件*/
        ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
        shiroFilter.setSecurityManager(securityManager);

        // 需要新增的過濾規則
        Map<String,Filter> filters = new HashMap<>();
        filters.put("jwt",jwtFilter);
        shiroFilter.setFilters(filters);


        Map<String,String> filterMap = shiroFilterChainDefinition.getFilterChainMap();
        shiroFilter.setFilterChainDefinitionMap(filterMap);
        return shiroFilter;
    }


}

3. 編寫realm

AccountRealm shiiro進行登陸或者許可權校驗的邏輯。

需要重寫三個方法。

  • supports:為了使realm支援jwt的憑證校驗
  • doGetAuthorizationInfo:許可權校驗
  • doGetAuthenticationInfo:登陸認證校驗

    @Slf4j
    @Component
    public class AccountRealm extends AuthorizingRealm{
    
      @Autowired
      JwtUtils jwtUtils;
    
      @Autowired
      UserService userService;
    
      /**
       * 判斷是否為jwt的token
       * @param token
       * @return
       */
      @Override
      public boolean supports(AuthenticationToken token) {
          return token instanceof JwtToken;
      }
    
      /**
       * 許可權驗證
       * @param principalCollection
       * @return
       */
      @Override
      protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
          return null;
      }
    
      /**
       * 登陸認證
       * @param authenticationToken
       * @return
       * @throws AuthenticationException
       */
      @Override
      protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
          // 將傳入的AuthenticationToken強轉JwtToken
          JwtToken jwtToken = (JwtToken) authenticationToken;
          // 獲取jwtToken中的userId
          String userId = jwtUtils.getClaimByToken((String) jwtToken.getPrincipal()).getSubject();
          // 根據jwtToken中的userId查詢資料庫
          User user = userService.getById(Long.valueOf(userId));
          if(user == null){
              throw new UnknownAccountException("賬戶不存在!");
          }
          if(user.getStatus() == -1){
              throw new LockedAccountException("賬戶已被鎖定!");
          }
          // 將可以顯示的資訊放在該載體中,對於密碼這種隱祕資訊不需要放在該載體中
          AccountProfile accountProfile = new AccountProfile();
          BeanUtils.copyProperties(user,accountProfile);
          log.info("jwt------------->{}",jwtToken);
          // 將token中使用者的基本資訊返回給shiro
          return new SimpleAuthenticationInfo(accountProfile,jwtToken.getCredentials(),getName());
      }
    }

    主要配置doGetAuthenticationInfo登陸認證這個方法,通過jwt憑證獲取使用者資訊,判斷使用者的狀態,最後異常就丟擲相應的異常資訊。

    4.編寫JwtToken

    shiro預設supports支援的是UsernamePasswordToken,而我們採用jwt的方式,故需要定義一個JwtToken來重寫該token。

    public class JwtToken implements AuthenticationToken{
      private String token;
      public JwtToken(String token){
          this.token = token;
      }
      @Override
      public Object getPrincipal() {
          return token;
      }
    
      @Override
      public Object getCredentials() {
          return token;
      }
    }

    5.編寫JwtUtils生成和校驗jwt的工具類

    有些jwt相關的金鑰資訊是從專案的配置檔案中獲取的。

    @Component
    @ConfigurationProperties(prefix = "mt.vuemtblog.jwt")
    public class JwtUtils {
      private String secret;
      private long expire;
      private String header;
    
      /**
       * 生成jwt token
       * @param userId
       * @return
       */
      public static  String generateToken(long userId){
          return null;
      }
    
      /**
       * 獲取jwt的資訊
       * @param token
       * @return
       */
      public static  Claims getClaimByToken(String token){
          return null;
      }
    
      /**
       * 驗證token是否過期
       * @param expiration
       * @return true 過期
       */
      public static boolean isTokenExpired(Date expiration){
          return expiration.before(new Date());
      }
    }

    6.編寫登陸成功返回使用者資訊的載體AccountProfile

    @Data
    public class AccountProfile implements Serializable {
      private Long id;
      private String username;
      private String avatar;
    }

    7. 全域性配置基本資訊

    shiro-redis:
    enabled: true
    redis-manger:
      host:127.0.0.1:6379
    
    mt:
    vuemtblog:
      jwt:
      #加密金鑰
      secret:f4e2e52034348f86b67cde581c0f9eb5
      # token 有效時長 7天 單位秒
      expire:604800
      # 設定token在header中的鍵值
      header:authorization

    8. 若專案使用spring-boot-devtools,需要新增一個配置檔案,

    在resources目錄下新建META-INF,然後新建spring-devtools.properties,這樣熱重啟就不會報錯。

    restart.include.shiro-redis=/shiro-[\\w-\\.]+jar

    9. 編寫自定義的JwtFileter過濾器

    這裡我們繼承的是Shiro內建的AuthenticatingFilter,一個可以內建了可以自動登入方法的的過濾器,有些同學繼承BasicHttpAuthenticationFilter也是可以的。

我們需要重寫幾個方法:

  1. createToken:實現登入,我們需要生成我們自定義支援的JwtToken
  2. onAccessDenied:攔截校驗,當頭部沒有Authorization時候,我們直接通過,不需要自動登入;當帶有的時候,首先我們校驗jwt的有效性,沒問題我們就直接執行executeLogin方法實現自動登入
  3. onLoginFailure:登入異常時候進入的方法,我們直接把異常資訊封裝然後丟擲
  4. preHandle:攔截器的前置攔截,因為我們是前後端分析專案,專案中除了需要跨域全域性配置之外,我們再攔截器中也需要提供跨域支援。這樣,攔截器才不會在進入Controller之前就被限制了。

    @Component
    public class JwtFilter extends AuthenticatingFilter{
    
     @Autowired
     JwtUtils jwtUtils;
     /**
      * 實現登陸,生成自定義的JwtToken
      * @param servletRequest
      * @param servletResponse
      * @return
      * @throws Exception
      */
     @Override
     protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
         HttpServletRequest request = (HttpServletRequest)servletRequest;
         String jwt = request.getHeader("Authorization");
         if(StringUtils.isEmpty(jwt)){
             return null;
         }
         return new JwtToken(jwt);
     }
    
     /**
      *  攔截校驗
      *  @description 當頭部沒有Authorization,直接通過,不需要自動登陸。
      *  當帶有Authorization時,需要先校驗jwt的時效性,沒問題直接執行executeLogin實現自動登陸,將token委託給shiro。
      * @param servletRequest
      * @param servletResponse
      * @return
      * @throws Exception
      */
     @Override
     protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
         HttpServletRequest request = (HttpServletRequest) servletRequest;
         // 獲取使用者請求頭中的token
         String token = request.getHeader("Authorization");
         if (StringUtils.isEmpty(token)) {// 沒有token
             return true;
         } else {
             // 校驗jwt
             Claims claim = jwtUtils.getClaimByToken(token);
             // tonken為空或者時間過期
             if (claim == null || jwtUtils.isTokenExpired((claim.getExpiration()))) {
                 throw new ExpiredCredentialsException("token以失效,請重新登陸!");
             }
         }
         // 執行自動登陸
         return executeLogin(servletRequest, servletResponse);
     }
    
     /**
      * 執行登入出現異常
      * @param token
      * @param e
      * @param request
      * @param response
      * @return
      */
     @Override
     protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
    
         HttpServletResponse httpServletResponse = (HttpServletResponse)response;
         // 1. 判斷是否因異常登陸失敗
         Throwable throwable = e.getCause() == null ? e : e.getCause();
         // 2.獲取登陸異常資訊以自定義的Resut響應格式返回json資料
         Result result = Result.error(throwable.getMessage());
         String json = JSONUtil.toJsonStr(result);// hutool的一個json工具
    
         // 3.列印響應
         try{
             httpServletResponse.getWriter().print(json);
         }catch (IOException ioException){
    
         }
         return false;
     }
    }

    三、springboot中全域性異常處理

    前後端分離,我們需要配置異常處理機制,返回一個友好簡單格式給前端。

處理方式:

  1. 通過@ControllerAdvice來進行統一異常處理
  2. 通過@ExceptionHandler(value=RuntimeException.class)指定要捕獲的Exception的各個型別,這個異常處理是全域性的,所有的類似異常都會捕獲。

    /**
     * 全域性異常處理
     */
    @Slf4j
    @RestControllerAdvice
    public class GlobalExcepitonHandler {
     // 捕捉shiro的異常
     @ResponseStatus(HttpStatus.UNAUTHORIZED)
     @ExceptionHandler(ShiroException.class)
     public Result handle401(ShiroException e) {
         return Result.fail(401, e.getMessage(), null);
     }
     /**
      * 處理Assert的異常
      */
     @ResponseStatus(HttpStatus.BAD_REQUEST)
     @ExceptionHandler(value = IllegalArgumentException.class)
     public Result handler(IllegalArgumentException e) throws IOException {
         log.error("Assert異常:-------------->{}",e.getMessage());
         return Result.fail(e.getMessage());
     }
     /**
      * @Validated 校驗錯誤異常處理
      */
     @ResponseStatus(HttpStatus.BAD_REQUEST)
     @ExceptionHandler(value = MethodArgumentNotValidException.class)
     public Result handler(MethodArgumentNotValidException e) throws IOException {
         log.error("執行時異常:-------------->",e);
         // 擷取所有必要的錯誤資訊;只顯示錯誤原因,不會顯示其他cause BY....
         BindingResult bindingResult = e.getBindingResult();
         ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();
         return Result.fail(objectError.getDefaultMessage());
     }
     /*
     * 執行時異常
     */
     @ResponseStatus(HttpStatus.BAD_REQUEST)
     @ExceptionHandler(value = RuntimeException.class)
     public Result handler(RuntimeException e) throws IOException {
         log.error("執行時異常:-------------->",e);
         return Result.fail(e.getMessage());
     }
    }

    上面我們捕捉了幾個異常:

    • ShiroException:shiro丟擲的異常,比如沒有許可權,使用者登入異常
    • IllegalArgumentException:處理Assert的異常
    • MethodArgumentNotValidException:處理實體校驗的異常
    • RuntimeException:捕捉其他異常

    1. springboot中實體校驗

    使用springboot框架,就自動整合了Hibernate validatior。

    第一步:實體屬性上新增校驗規則

    @TableName("m_user")
    public class User implements Serializable {
     private static final long serialVersionUID = 1L;
     @TableId(value = "id", type = IdType.AUTO)
     private Long id;
     
     @NotBlank(message = "暱稱不能為空")
     private String username;
     private String avatar;
    
     @NotBlank(message = "郵箱不能為空")
     @Email(message = "郵箱格式不正確")
     private String email;
    }

    第二步:測試實體校驗

    採用@Validated註解,實體中有不符合校驗規則的,會丟擲異常,在異常處理中的MethodArgumentNotValidException中捕獲。

    @PostMapping("/save")
     public Object save(@Validated @RequestBody User user) {
    
         return user.toString();
     }

    四、前後端分離的跨域處理

    在後臺進行全域性跨域處理

    /**
     * 解決跨域問題
     * project: vue-mt-blog
     * created by Maotao on 2020/6/30
     */
    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
     @Override
     public void addCorsMappings(CorsRegistry registry) {
         registry.addMapping("/**").
                 allowedOrigins("*").
                 allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS").
                 allowCredentials(true).
                 maxAge(3600).
                 allowedHeaders("*");
     }
    }
    

    全域性跨域處理

    /**
     * 解決跨域問題
     * project: vue-mt-blog
     * created by Maotao on 2020/6/30
     */
    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
     @Override
     public void addCorsMappings(CorsRegistry registry) {
         registry.addMapping("/**").
                 allowedOrigins("*").
                 allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS").
                 allowCredentials(true).
                 maxAge(3600).
                 allowedHeaders("*");
     }
    }

相關文章