Dubbo學習系列之九(Shiro+JWT權限管理)


村長讓小王給村里各系統來一套SSO方案做整合,隔壁的陳家村流行使用Session+認證中心方法,但小王想嘗試點新鮮的,於是想到了JWT方案,那JWT是啥呢?JavaWebToken簡稱JWT,就是一個字符串,由點號連接,可以Encoded和Decoded進行明文和密文轉換,結構如下:

 

 

頭部,聲明和簽名,頭部(header)說明加密算法、類型等,聲明(payload)內容如賬號密碼信息或需要傳輸的內容,簽名(signature)即對聲明進行加密生成的簽名,用於防篡改。這樣,SSO就無需認證中心了,也無需服務端進行服務端session存儲,甚至不使用cookie傳輸,無CSRF風險,每次request攜帶上這個token,服務方通過認證即可,鏈路簡單,僅需各服務使用統一私鑰和驗證算法即可。

聽完小王的SSO方案,村長略顯興奮,看小王才堪大用,於是再提出一項要求,讓來套權限控制方案,有了前面的經驗,小王也想到了SpringSecurity,但得讓村長滿意,必須有些與眾不同,於是說了他的Shiro方案,村長認真的點點頭,高興地表示可以出面協助解決找對象的問題。在此,我們也來研究下小王的這套技術,說不定還可以解決一些生活問題。

准備: Idea201902/JDK11/ZK3.5.5/Gradle5.4.1/RabbitMQ3.7.13/Mysql8.0.11/Lombok0.26/Erlang21.2/postman7.5.0/Redis3.2/RocketMQ4.5.2

難度:新手--戰士--老兵--大師

目標:1.模擬商城系統,實現服務間SSO    2.使用JWT+Shiro實現權限管理

步驟

1.系統整體框架不變,增加admin模塊,作為sso認證和權限管理服務,整體思路:首次請求,進行DB用戶信息驗證,通過后生成一個jwtToken,並獲取各類權限,再次訪問,則請求頭帶上這個jwtToken,服務端僅進行token校驗,並刷新Token有效期。

2.幾個shiro的核心對象:

  • Principal 主體身份標識,必須具有唯一性,如用戶名、手機號、郵箱地址等,一個主體可以有多個身份,但是必須有一個主身份(Primary Principal);

  • Subject 請求主體,一個登錄用戶,一個請求等,在程序中任何地方都可以通過SecurityUtils.getSubject()獲取到當前的subject,subject中又可以獲取到Principal;

  • credential 憑證信息,只有主體知道的安全信息,如密碼等;

  • SecurityManager 權限控制中心,所有請求最終基本上都通過它來代理轉發,一般我們程序中不需要直接跟他打交道;

  • Realm 認證領域,不同的數據源使用不同的認證領域,比如從DB取信息對比的可以叫DbRealm ,從Redis取緩存信息對比認證的叫RedisRealm,一般情況下我們對每種數據源定義一個Realm,其中包含了比對器(Matcher);

  • authenticator 認證器,主體進行認證最終通過authenticator進行的;

  • authorizer 授權器,主體進行授權最終通過authorizer進行的;

3.因為要用到JWT,也做個簡要說明,使用了auth0包,主要在 com.biao.mall.admin.util.JwtUtils中,其中方法包含生成JwtToken,加解密,簽名等,比較清晰。

 

 1 public class JwtUtils {
 2 
 3     /**
 4      * 獲得token中的信息無需secret解密也能獲得
 5      * @return token中包含的簽發時間
 6      */
 7     public static LocalDateTime getIssueAt(String token){
 8         DecodedJWT jwt = JWT.decode(token);
 9         return TimeUtil.convert2LocalTime(jwt.getIssuedAt());
10     }
11 
12     /**
13      * 獲得token中的信息無需secret解密也能獲得
14      * @return token中包含的用戶名
15      */
16     public static String getUsername(String token){
17         DecodedJWT jwt = JWT.decode(token);
18         return jwt.getClaim("username").asString();
19     }
20 
21     /**
22      * 生成簽名,expireTime后過期
23      * @param username 用戶名
24      * @param expireTime 過期時間s
25      * @return 加密的token
26      */
27     public static String sign(String username, String salt, long expireTime) {
28         Date date = new Date(System.currentTimeMillis()+expireTime*1000);
29         Algorithm algorithm= Algorithm.HMAC256(salt);
30         //
31         return JWT.create()
32                 .withClaim("username",username)
33                 .withExpiresAt(date)
34                 .withIssuedAt(new Date())
35                 .sign(algorithm);
36     }
37 
38     /**
39      * token是否過期
40      * @return true:過期
41      */
42     public static boolean isTokenExpired(String token){
43         Date now = Calendar.getInstance().getTime();
44         DecodedJWT jwt = JWT.decode(token);
45         return jwt.getExpiresAt().before(now);
46     }
47 
48     /**
49      * 生成隨機鹽,長度32位
50      * @return
51      */
52     public static String generateSalt(){
53         SecureRandomNumberGenerator secureRandom = new SecureRandomNumberGenerator();
54         String hex = secureRandom.nextBytes(16).toHex();
55         return hex;
56     }
57 
58 }

 

JWT加解密示例請看這里:https://jwt.io/#debugger-io

4.基礎組件com.biao.mall.admin.service.UserService,也比較簡單清晰,“加密鹽”,即對加密對象加入的一些干擾數據,增加復雜度,要注意加解密的鹽要一致:

@Service
public class UserService {

    private final static Logger lgger = LoggerFactory.getLogger(UserService.class);
    //加密用戶信息的鹽
    private static final String encryptSalt = "510fdb7f28534fb584af25697826c203";
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    public UserService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public String generateJwtToken(String username){
        //加密JWT的鹽
        String salt = "0805c99fd2634c80b2cde8c7e4124468";
        //redis緩存salt
        stringRedisTemplate.opsForValue().set("token:"+username, salt, 3600, TimeUnit.SECONDS);
        return JwtUtils.sign(username,salt,60*60);//生成jwt token,設置過期時間為1小時
    }

    /*
     * 獲取上次token生成時的salt值和登錄用戶信息*/
    public UserDto getJwtToken(String username) {
//        String salt = "9723612f53";
        //從數據庫或者緩存中取出jwt token生成時用的salt
        String salt = stringRedisTemplate.opsForValue().get("token:"+username);
        UserDto userDto = this.getUserInfo(username);
        userDto.setSalt(salt);
        return userDto;
    }

    /**
     * 獲取數據庫中保存的用戶信息,主要是加密后的密碼.這里省去了DB操作,直接生成了用戶信息
     * @param username
     * @return
     */
    public UserDto getUserInfo(String username){
        UserDto user =  new UserDto();
        user.setUserId(1L);
        user.setUsername("admin");
        //模擬對密碼加密
        user.setEncryptPwd(new Sha256Hash("admin123",encryptSalt).toHex());
        lgger.debug("UserService: [{}]",user.toString());
        return user;
    }

    /**清除token信息*/
    public void deleteLogInfo(String username){
         // 刪除數據庫或者緩存中保存的salt
//        stringRedisTemplate.delete("token:"+username);
    }

    /**獲取用戶角色列表,強烈建議從緩存中獲取*/
    public List<String> getUserRoles(Long userId){
        //模擬admin角色
        return Arrays.asList("admin");
    }
}

 

5.配置類 com.biao.mall.admin.conf.ShiroConf功能就是:

  • 通過FilterRegistrationBean注入自定義的權限Filter和認證Filter
  • 注冊Authenticator,關聯定義的多個Realm
  • 注冊ShiroFilterChainDefinition
  • 注冊sessionStorageEvaluator禁用session
@Configuration
public class ShiroConf {

    /**注冊shiro的Filter 攔截請求*/
    @Bean
    public FilterRegistrationBean<Filter> filterRegistrationBean(SecurityManager securityManager, UserService userService) throws Exception {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter((Filter) Objects.requireNonNull(this.shiroFilter(securityManager, userService).getObject()));
        filterRegistrationBean.addInitParameter("targetFilterLifecycle","true");
        //bean注入開啟異步方式
        filterRegistrationBean.setAsyncSupported(true);
        filterRegistrationBean.setEnabled(true);
        filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST);
        return filterRegistrationBean;
    }

    /**設置過濾器,將自定義的Filter加入*/
    @Bean(name = "shiroFilter")
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager, UserService userService) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
        //必需屬性,指定一個SecurityManager的實例,
        factoryBean.setSecurityManager(securityManager);
        Map<String,Filter> filterMap = factoryBean.getFilters();
        filterMap.put("authcToken",this.createAuthFilter(userService));
        filterMap.put("anyRole",this.createRolesFilter());
        factoryBean.setFilters(filterMap);
        factoryBean.setFilterChainDefinitionMap(this.shiroFilterChainDefinition().getFilterChainMap());
        return  factoryBean;
    }

    @Bean
    protected ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        chainDefinition.addPathDefinition("/login", "noSessionCreation,anon");
        chainDefinition.addPathDefinition("/logout", "noSessionCreation,authcToken[permissive]");
        chainDefinition.addPathDefinition("/image/**", "anon");
        //只允許admin或manager角色的用戶訪問
        chainDefinition.addPathDefinition("/admin/**", "noSessionCreation,authcToken,anyRole[admin,manager]");
        chainDefinition.addPathDefinition("/**", "noSessionCreation,authcToken");
        return chainDefinition;
    }

    /**注意不要加@Bean注解,不然spring會自動注冊成filter*/
    private AnyRolesAuthorizationFilter createRolesFilter() {
        return new AnyRolesAuthorizationFilter();
    }

    /**注意不要加@Bean注解,不然spring會自動注冊成filter*/
    private JwtAuthFilter createAuthFilter(UserService userService) {
        return new JwtAuthFilter(userService);
    }

    /**初始化authenticator*/
    @Bean
    public Authenticator authenticator(UserService userService){
        ModularRealmAuthenticator authenticator = new ModularRealmAuthenticator();
        authenticator.setRealms(Arrays.asList(this.jwtShiroRealm(userService),this.dbShiroRealm(userService)));
        //如果有多個Realms才需要指定realm匹配策略
        authenticator.setAuthenticationStrategy(new FirstSuccessfulStrategy());
        return authenticator;
    }

    /**DB認證的realm*/
    @Bean("dbRealm")
    public Realm dbShiroRealm(UserService userService){
        DbShiroRealm dbShiroRealm = new DbShiroRealm(userService);
        return dbShiroRealm;
    }

    /**JWT 認證的realm*/
    @Bean("jwtRealm")
    public Realm jwtShiroRealm(UserService userService) {
        JWTShiroRealm  myShiroRealm = new JWTShiroRealm(userService);
        return  myShiroRealm;
    }

    /**禁用session,不保存用戶狀態,每次請求都重新認證,
     * 要完全禁用session,需使用下面的filter來實現*/
    @Bean
    protected SessionStorageEvaluator sessionStorageEvaluator(){
        DefaultWebSessionStorageEvaluator sessionStorageEvaluator = new DefaultWebSessionStorageEvaluator();
        sessionStorageEvaluator.setSessionStorageEnabled(false);
        return sessionStorageEvaluator;
    }

}

 

從以上內容並結合其他部分可整理出shiro內部組件關系圖,或者說大致的處理流程: WeChat Image_20190908114030

6.然后我們看首次登錄流程,從com.biao.mall.admin.controller.AdminController開始,看其核心部分:

@PostMapping(value = "/login")
    public ResponseEntity<Void> login(@RequestBody UserDto loginInfo, HttpServletRequest request, HttpServletResponse response){
        //獲取請求主體
        Subject subject = SecurityUtils.getSubject();
        try {
            //將用戶請求參數封裝
            UsernamePasswordToken token = new UsernamePasswordToken(loginInfo.getUsername(), loginInfo.getPassword());
            /**直接提交給Shiro處理,進入內部驗證,如果驗證失敗,返回AuthenticationException,如果通過,就將全部認證信息關聯到
             * 此Subject上,subject.getPrincipal()將非空,且subject.isAuthenticated()為True*/
            subject.login(token);
            logger.info(">>AdminController.login OK!");
            UserDto user = (UserDto) subject.getPrincipal();
            String newToken = userService.generateJwtToken(user.getUsername());
            //寫入響應信息返回
            response.setHeader("x-auth-token", newToken);
            return ResponseEntity.ok().build();
        } catch (AuthenticationException e) {
            // 如果校驗失敗,shiro會拋出異常,返回客戶端失敗
            logger.error("User {} login fail, Reason:{}", loginInfo.getUsername(), e.getMessage());
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    @GetMapping("/logout")
    public ResponseEntity logout(){
        Subject subject = SecurityUtils.getSubject();
        if (subject.getPrincipals() != null){
            UserDto userDto = (UserDto) subject.getPrincipals().getPrimaryPrincipal();
            userService.deleteLogInfo(userDto.getUsername());
        }
        //務必不能少
        SecurityUtils.getSubject().logout();
        return ResponseEntity.ok().build();
    }

 

先封裝一個UsernamePasswordToken,此類實現接口HostAuthenticationToken和RememberMeAuthenticationToken,前一個接口用於記住認證請求的HostName或IP,后一個接口用於實現跨session的“記住密碼”功能,另一細節是此類用char[]而不是String來存pwd,為啥?因為String是不可變的,會放到常量池中,留存較長時間,某些場合如memory dump時,可直接被輸出訪問。username/password模式認證場景最為常見,故shiro特意設計了UsernamePasswordToken來使用的。重點是以下一行:

subject.login(token);

就能將認證工作交給shiro去處理:進入內部自動驗證,如果驗證失敗,返回AuthenticationException;如果通過,就將全部認證信息關聯到此Subject上,subject.getPrincipal()將非空,且subject.isAuthenticated()為True。 最后是如果驗證成功,將生成一個newToken,並寫入響應的頭。

7.再進一步,看shiro如何內部自動驗證:shiro調用已注冊的Authenticator,Authenticator自動選擇對應的Realm。Realm的實現一般直接繼承AuthorizingRealm即可:

public class DbShiroRealm extends AuthorizingRealm {
    private final Logger logger = LoggerFactory.getLogger(JWTShiroRealm.class);
    //生產環境鹽值不可硬編碼在代碼中,注意與前面設置的一致
    private static final String encrySalt = "510fdb7f28534fb584af25697826c203";//對比登錄信息的salt
    private UserService userService;

    public DbShiroRealm(UserService userService) {
        this.userService = userService;
        this.setCredentialsMatcher(new HashedCredentialsMatcher(Sha256Hash.ALGORITHM_NAME));
    }

    @Override
    public boolean supports(AuthenticationToken token){
        logger.info(">>DbShiroRealm.supports");
        return token instanceof UsernamePasswordToken;
    }

    /**權限*/
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        //獲取主身份標識
        UserDto userDto = (UserDto) principals.getPrimaryPrincipal();
        //獲取權限角色
        List<String> roles = userDto.getRoles();
        if (roles == null){
            roles = userService.getUserRoles(userDto.getUserId());
            userDto.setRoles(roles);
        }
        if (roles != null){
            simpleAuthorizationInfo.addRoles(roles);
        }
        return simpleAuthorizationInfo;
    }

    /**認證*/
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token;
        String userName = usernamePasswordToken.getUsername();
        UserDto userDto = userService.getUserInfo(userName);
        if (userDto == null){
            throw new AuthenticationException("userName or pwd error!");
        }
        return new SimpleAuthenticationInfo(userDto,userDto.getEncryptPwd(), ByteSource.Util.bytes(encrySalt),"dbRealm");
    }
}

 

方法之一 :supports(AuthenticationToken token),即根據token判斷此Authenticator是否使用該realm,

@Override
    public boolean supports(AuthenticationToken token){
        return token instanceof UsernamePasswordToken;
    }

 

方法之二:doGetAuthorizationInfo,做權限處理,需注意這里兩次使用了roles獲取邏輯,因為Shiro默認不會緩存角色信息,所以這里調用service的方法獲取角色,且強烈建議service中從緩存中獲取。

/**權限*/
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        //獲取主身份標識
        UserDto userDto = (UserDto) principals.getPrimaryPrincipal();
        //獲取權限角色
        List<String> roles = userDto.getRoles();
        if (roles == null){
            roles = userService.getUserRoles(userDto.getUserId());
            userDto.setRoles(roles);
        }
        if (roles != null){
            simpleAuthorizationInfo.addRoles(roles);
        }
        return simpleAuthorizationInfo;
    }

 

方法之三:doGetAuthenticationInfo,做認證,此處是首次認證,故強轉為UsernamePasswordToken,再去DB中使用userService.getUserInfo(userName)取得存儲的賬戶信息,最后構造成SimpleAuthenticationInfo扔給shiro。

  /**認證*/
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token;
        String userName = usernamePasswordToken.getUsername();
        UserDto userDto = userService.getUserInfo(userName);
        if (userDto == null){
            throw new AuthenticationException("userName or pwd error!");
        }
        return new SimpleAuthenticationInfo(userDto,userDto.getEncryptPwd(), ByteSource.Util.bytes(encrySalt),"dbRealm");
    }

 

那究竟是如何對比的呢?最后是落到了HashedCredentialsMatcher頭上,並使用Hash算法,因為這個user/pwd比對比較簡單固定,所以shiro已經有了matcher,直接引用即可!至此,首次登錄認證結束!

  public DbShiroRealm(UserService userService) {
        this.userService = userService;
        this.setCredentialsMatcher(new HashedCredentialsMatcher(Sha256Hash.ALGORITHM_NAME));
    }

 

8.非首次登錄,先是com.biao.mall.admin.filter.JwtAuthFilter處理,事實上無論哪次請求,都會經過這個Filter處理:

@Slf4j
public class JwtAuthFilter extends AuthenticatingFilter {
    private final Logger logger = LoggerFactory.getLogger(JwtAuthFilter.class);
    private static final int tokenRefreshInterval = 300;
    private UserService userService;

    public JwtAuthFilter(UserService userService){
        this.userService = userService;
        this.setLoginUrl("/login");
    }

    @Override
    protected boolean preHandle(ServletRequest request,ServletResponse response) throws Exception {
        logger.info("JwtAuthFilter.preHandle");
        HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
        //對於OPTION請求做攔截,不做token校驗
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())){
            return false;
        }
        return super.preHandle(request,response);
    }

    @Override
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
        logger.info("JwtAuthFilter.createToken");
        String jwtToken = this.getAuthzHeader(request);
        if (StringUtils.isNotBlank(jwtToken) && !JwtUtils.isTokenExpired(jwtToken)){
            return new JWTToken(jwtToken);
        }
        return null;
    }

    private String getAuthzHeader(ServletRequest request) {
        logger.info("JwtAuthFilter.getAuthzHeader");
        HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
        String header = httpServletRequest.getHeader("x-auth-token");
        return StringUtils.remove(header,"Bearer");
    }

    //cors 跨域設置
    private void fillCorsHeader(HttpServletRequest toHttp, HttpServletResponse httpServletResponse) {
        httpServletResponse.setHeader("Access-control-Allow-Origin",toHttp.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods","GET,POST,OPTIONS,HEAD");
        httpServletResponse.setHeader("Access-Control-Allow-Headers",toHttp.getHeader("Access-Control-Request-Headers"));
    }

    @Override
    protected boolean isAccessAllowed(ServletRequest request,ServletResponse response,Object mappedValue){
        logger.info(">>JwtAuthFilter.isAccessAllowed");
        if (this.isLoginRequest(request,response)){
            return true;
        }
        Boolean afterFiltered = (Boolean) request.getAttribute("anyRolesAuthFilter.FILTERED");
        if (BooleanUtils.isTrue(afterFiltered)){
            return true;
        }
        boolean allowed = false;
        try{
            allowed = executeLogin(request,response);
        }catch (IllegalStateException e){
            logger.error("Not found any token");
        }catch (Exception e){
            logger.error("Error occurs when login",e);
        }
        return allowed || super.isPermissive(mappedValue);
    }

    //isAccessAllowed返回 false進入此方法
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json;charset=UTF-8");
        httpServletResponse.setStatus(HttpStatus.SC_NON_AUTHORITATIVE_INFORMATION);
        this.fillCorsHeader(WebUtils.toHttp(request),httpServletResponse);
        return false;
    }

    @Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response){
        logger.error("Validate token fail, token:{}, error:{}",token.toString(),e.getMessage());
        return false;
    }

    @Override
    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response){
        HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
        String newToken = null;
        if (token instanceof JWTToken){
            JWTToken jwtToken = (JWTToken) token;
            UserDto userDto = (UserDto) subject.getPrincipals().getPrimaryPrincipal();
            boolean shouldRefresh = this.shouldTokenRefresh(JwtUtils.getIssueAt(jwtToken.getToken()));
            if (shouldRefresh){
                newToken = userService.generateJwtToken(userDto.getUsername());
            }
        }
        if (StringUtils.isNotBlank(newToken)){
            httpServletResponse.setHeader("x-auth-token",newToken);
        }
        return true;
    }

    private boolean shouldTokenRefresh(LocalDateTime issueAt) {
//        LocalDateTime issueTime = LocalDateTime.ofInstant(issueAt.toInstant(), ZoneId.systemDefault());
        return LocalDateTime.now().minusSeconds(tokenRefreshInterval).isAfter(issueAt);
    }

    @Override
    protected void postHandle(ServletRequest request,ServletResponse response){
        this.fillCorsHeader(WebUtils.toHttp(request),WebUtils.toHttp(response));
        request.setAttribute("jwtShiroFilter.FILTERED", true);
    }

}

 

展開,isAccessAllowed見名知意,邏輯:如果是首次,通過;如果已FILTERED,通過;如果都不是,則調用父類executeLogin方法,跟進一下,這里面再調用subject.login(token),其實就是前面首次登錄邏輯了!父類會在請求進入攔截器后調用該方法,返回true則繼續,返回false則會調用onAccessDenied()。不通過時,還會調用了isPermissive()方法。

 1  @Override
 2     protected boolean isAccessAllowed(ServletRequest request,ServletResponse response,Object mappedValue){
 3         if (this.isLoginRequest(request,response)){
 4             return true;
 5         }
 6         Boolean afterFiltered = (Boolean) request.getAttribute("anyRolesAuthFilter.FILTERED");
 7         if (BooleanUtils.isTrue(afterFiltered)){
 8             return true;
 9         }
10         boolean allowed = false;
11         try{
12             allowed = executeLogin(request,response);
13         }catch (IllegalStateException e){
14             logger.error("Not found any token");
15         }catch (Exception e){
16             logger.error("Error occurs when login",e);
17         }
18         return allowed || super.isPermissive(mappedValue);
19     }

 

關於父類的isPermissive()方法:對參數進行搜索,看是否有PERMISSIVE = "permissive"字符串,

protected boolean isPermissive(Object mappedValue) {
        if(mappedValue != null) {
            String[] values = (String[]) mappedValue;
            return Arrays.binarySearch(values, PERMISSIVE) >= 0;
        }
        return false;
    }

 

那為啥要加上"||super.isPermissive(mappedValue)",因為比如/logout請求,就能繼續處理,這里也對應了前面ShiroFilterChainDefinition中的:

chainDefinition.addPathDefinition("/logout", "noSessionCreation,authcToken[permissive]"); 

這種場景同樣適用於其他未登錄,但又可以操作的場景,比如只是閱讀內容不做評論,或者查詢操作等。 來看方法createToken,

1  @Override
2     protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
3         String jwtToken = this.getAuthzHeader(request);
4         if (StringUtils.isNotBlank(jwtToken) && !JwtUtils.isTokenExpired(jwtToken)){
5             return new JWTToken(jwtToken);
6         }
7         return null;
8     }

 

重寫了父類的方法,使用我們自己定義的Token類,提交給shiro。這個方法返回null的話會直接拋出異常,進入isAccessAllowed()的異常處理邏輯 。

9.再看方法:onLoginSuccess,如果Login認證成功,會進入該方法,等同於用戶名密碼登錄成功,這里還判斷了是否要刷新Token,為啥要刷新token?因為每個token都有設置過期時間,刷新,可防止舊token被非法使用,如果是安全性要求高的系統,可以在update類操作后就刷新token,降低風險。

@Override
    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response){
        HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
        String newToken = null;
        if (token instanceof JWTToken){
            JWTToken jwtToken = (JWTToken) token;
            UserDto userDto = (UserDto) subject.getPrincipal();
            boolean shouldRefresh = this.shouldTokenRefresh(JwtUtils.getIssueAt(jwtToken.getToken()));
            if (shouldRefresh){
                newToken = userService.generateJwtToken(userDto.getUsername());
            }
        }
        if (StringUtils.isNotBlank(newToken)){
            httpServletResponse.setHeader("x-auth-token",newToken);
        }
        return true;
    }

 

另一方法:onLoginFailure,如果調用shiro的Login認證失敗,會回調這個方法,這里直接返回false,因為邏輯放到了onAccessDenied()中,

@Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response){
        logger.error("Validate token fail, token:{}, error:{}",token.toString(),e.getMessage());
        return false;
    }

 

如果調用shiro的login認證失敗,會回調這個方法,這里我們什么都不做,因為邏輯放到了onAccessDenied()中。

10.關於自定義的:com.biao.mall.admin.dto.JWTToken,很簡單,略,

//@Data
public class JWTToken implements HostAuthenticationToken {
    private static final long serialVersionUID  = 8765431346463134621L;

    private String token;
    private String host;

    public JWTToken(String token,String host){
        this.token = token;
        this.host = host;
    }

    public JWTToken(String token){
        //借用全變量構造函數
        this(token,null);
    }

    public void setToken(String token) {
        this.token = token;
    }

    public void setHost(String host) {
        this.host = host;
    }

    public String getToken() {
        return this.token;
    }

    public String getHost() {
        return this.host;
    }

    /**注意這里的重寫方法,后續使用中,以此處返回值為准*/
    @Override
    public Object getPrincipal() {
        return this.token;
    }

    /**注意這里的重寫方法,后續使用中,以此處返回值為准*/
    @Override
    public Object getCredentials() {
        return this.token;
    }

    @Override
    public String toString(){
        return token + ':' + host;
    }
}

 

既然shiro將JWTToken交給Realm處理,先看會使用到的 com.biao.mall.admin.conf.JWTShiroRealm

/**
 * @Classname JWTShiroRealm  自定義身份認證
 *  * 基於HMAC( 散列消息認證碼)的控制域
 * @Description TODO
 * @Author xiexiaobiao
 * @Date 2019-09-05 22:48
 * @Version 1.0
 **/
public class JWTShiroRealm extends AuthorizingRealm {
    private static final Logger logger = LoggerFactory.getLogger(JWTShiroRealm.class);
    private UserService userService;

    public JWTShiroRealm(UserService userService) {
        this.userService = userService;
        this.setCredentialsMatcher(new JWTCredentialsMatcher());
    }

    @Override
    public boolean supports(AuthenticationToken token){
        logger.debug("token instanceof JWTToken >> {}", (token instanceof JWTToken));
        return (token instanceof JWTToken);
    }

    //首次登錄已經處理權限角色,故這里不需處理
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return new SimpleAuthorizationInfo();
    }

    //
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        JWTToken jwtToken = (JWTToken) token;
        String tokenStr = jwtToken.getToken();
        UserDto userDto = userService.getJwtToken(JwtUtils.getUsername(tokenStr));
        if (userDto == null){
            throw new  AuthenticationException("token expired ,please login");
        }
        return new SimpleAuthenticationInfo(userDto,userDto.getSalt(),"jwtRealm");
    }
}

 

這里可以通過和DbShiroRealm對比分析:supports方法看此realm是否匹配,符合才進入處理

 @Override
    public boolean supports(AuthenticationToken token){
        logger.debug("token instanceof JWTToken >> {}", (token instanceof JWTToken));
        return (token instanceof JWTToken);
    }

 

看相同名稱的doGetAuthorizationInfo方法:首次登錄已經處理權限角色,故這里不需處理,JWTtoken中也不包含角色信息。

@Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return new SimpleAuthorizationInfo();
    }

 

看另一相同名稱的doGetAuthenticationInfo方法:取得token后,直接交給jwtRealm處理。

   @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        JWTToken jwtToken = (JWTToken) token;
        String tokenStr = jwtToken.getToken();
        UserDto userDto = userService.getJwtToken(JwtUtils.getUsername(tokenStr));
        if (userDto == null){
            throw new  AuthenticationException("token expired ,please login");
        }
        SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(userDto,userDto.getSalt(),"jwtRealm");
        return authenticationInfo;
    }

 

同理,jwtRealm要指定Matcher,這里的jwtRealm,通過構造函數指定了JWTCredentialsMatcher,

public JWTShiroRealm(UserService userService) {
        this.userService = userService;
        this.setCredentialsMatcher(new JWTCredentialsMatcher());
    }

 

既然使用到了CredentialsMatcher,看定義,用指定的算法做匹配驗證:

public class JWTCredentialsMatcher implements CredentialsMatcher {
    private final Logger logger = LoggerFactory.getLogger(JWTCredentialsMatcher.class);

    @Override
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
        String tokenStr = (String) token.getCredentials();
        Object stored = info.getCredentials();
        String salt = stored.toString();

        UserDto userDto = (UserDto) info.getPrincipals().getPrimaryPrincipal();
        try{
            Algorithm algorithm = Algorithm.HMAC256(salt);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("username",userDto.getUsername())
                    .build();
            verifier.verify(tokenStr);
            return true;
        }catch (JWTVerificationException e){
            logger.error("Token Error:{}", e.getMessage());
        }
        return false;
    }
}

 

至此,非首次登錄邏輯也結束了!

11.說了這么多,似乎還沒說到角色咋回事,先看前面的ShiroFilterChainDefinition內容:

 chainDefinition.addPathDefinition("/admin/**", "noSessionCreation,authcToken,anyRole[admin,manager]");
  chainDefinition.addPathDefinition("/**", "noSessionCreation,authcToken");

 

shiro中是通過AuthorizationFilter來進行角色過濾,邏輯就是在請求進入這個filter后,shiro會調用所有配置的Realm獲取用戶的角色信息,然后和Filter中配置的角色做對比,匹配就可以通過,也就是各realm中的doGetAuthorizationInfo方法返回的AuthorizationInfo對象,注意默認的Filter只提供‘並’比對,比如‘Role[admin,manager]’即表示要具備admin和manager角色,上面的'authcToken'即表示要通過用戶認證,項目中自定義了AnyRolesAuthorizationFilter,故‘anyRole[admin,manager]’表示要具備admin或manager角色,其實,shiro還提供了注解模式,比如@RequiresRoles("admin"),即表示需要admin角色:

@RequiresRoles("admin")
    @GetMapping("/test")
    public ResponseEntity test(){
        return null;
    }

 

再來看AnyRolesAuthorizationFilter,重寫了isAccessAllowed方法,其中實現了role的‘或’比對,

public class AnyRolesAuthorizationFilter extends AuthorizationFilter {

    @Override
    protected void postHandle(ServletRequest request, ServletResponse response){
        request.setAttribute("anyRolesAuthFilter.FILTERED", true);
    }
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        Boolean afterFiltered = (Boolean) request.getAttribute("anyRolesAuthFilter.FILTERED");
        if (BooleanUtils.isTrue(afterFiltered)){
            return true;
        }
        Subject subject = getSubject(request,response);
        String[] rolesArray = (String[]) mappedValue;
        //沒有角色限制,有權限訪問
        if (rolesArray == null || rolesArray.length == 0 ){
            return true;
        }
        for (String role : rolesArray
             ) {
            if (subject.hasRole(role)){
                return true;
            }
        }
        return false;
    }

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
        HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json;charset=utf-8");
        httpServletResponse.setStatus(HttpStatus.SC_UNAUTHORIZED);
        return false;
    }
}

 

提一下session禁用:因為用了jwt的訪問認證,所以要把默認session支持關掉,前面conf中通過sessionStorageEvaluator禁用,還需要加上以下配置,因為有些請求,並沒有通過認證但也可以繼續訪問,因此這里對所有URL做設置;

chainDefinition.addPathDefinition("/**", "noSessionCreation,authcToken"); 

12.SSO改造:即在admin模塊設計一個專門的登錄認證服務,供其他服務RPC調用,具體在com.biao.mall.admin.service.AuthServiceImpl,其他服務使用filter或interceptor,過濾后直接調用此方法,二次登錄,可以在各自服務內實現,后續我再完善。

@Override
    public String loginAuth(UserDto loginInfo) {
        Subject subject = SecurityUtils.getSubject();
        try{
            UsernamePasswordToken token = new UsernamePasswordToken(loginInfo.getUsername(),loginInfo.getPassword());
            subject.login(token);
            UserDto userDto = (UserDto) subject.getPrincipals().getPrimaryPrincipal();
            String newToken = userService.generateJwtToken(userDto.getUsername());
            return newToken;
        } catch (AuthenticationException e) {
            logger.error("User {} loginAuth fail, Reason:{}", loginInfo.getUsername(), e.getMessage());
        } catch (Exception e) {
            logger.error("User {} loginAuth fail, Reason:{}", loginInfo.getUsername(), e.getMessage());
        }
        return null;
    }

 

13.終於到了測試了,寫的都快暈了,啟動:ZK-->Redis-->Rocket-->Stock-->Business-->Logistic-->Admin, 模擬login:

 

 

提交后,獲得JWT:

 

 

 

 

做個jwt合法驗證, 如果填寫錯誤的jwt加密鹽:

 

 

填寫正確的salt后:

 

 

輸入錯誤的username和pwd,會提示:

2019-09-07 18:49:27.509 ERROR 15816 --- [nio-8087-exec-4] c.b.m.admin.controller.AdminController   : User admin login fail, Reason:No account information found for authentication token [org.apache.shiro.authc.UsernamePasswordToken - admin, rememberMe=false] by this Authenticator instance.  Please check that it is configured correctly.

 

14.二次登錄測試權限,controller中寫兩個測試URL,並配上角色權限要求:

@RequiresRoles("manager")
    @GetMapping("/manager")
    public ResponseEntity test(HttpServletRequest request, HttpServletResponse response){
        return ResponseEntity.ok(request.getHeader("x-auth-token"));
    }

    @RequiresRoles("admin")
    @GetMapping("/admin")
    public ResponseEntity test2(HttpServletRequest request, HttpServletResponse response){
        return ResponseEntity.ok(request.getHeader("x-auth-token"));
    }

 

首次訪問生成JWT:

 

 

 攜帶正確的JWT訪問,但無"manager"權限情況:

 

 

 

攜帶正確的JWT訪問,有"admin"權限:

 

 

15.項目代碼地址:其中的day12 https://github.com/xiexiaobiao/dubbo-project.git

后記:

1.JWT的優缺點:JWT不僅可用於認證,還可用於信息交換,優點就是簡單,保存在客戶端,可減輕服務端負載,最大缺點就是服務器無狀態,所以在使用期間,無法取消或更改token權限,即jwt一旦簽發,有效期內將一直有效。另外,jwt本身包含身份驗證信息,一旦泄漏,將可非法獲得token的所有權限。

2.shiro比較springSecurity:shiro優點就是輕量級,完全不依賴spring,適用於常見的權限管理場景,springSecurity對spring整合較好,實現了一些組件功能。很多概念兩者相通或近似,springSecurity更為復雜。

3.權限控制使用攔截器Interceptor也是可以的,

4.本項目代碼,參考了他人簡書上博文代碼,免得重復造輪子,

往期文章推薦:

我的個人公眾號:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM