• 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.本项目代码,参考了他人简书上博文代码,免得重复造轮子,

    往期文章推荐:

    我的个人公众号:

  • 相关阅读:
    nacos 使用笔记
    mongodb 操作笔记
    maven 编译指定模块
    linux Java 手动GC 手动回收垃圾
    mysql 严格模式取消 group by 和 date zore
    MyBatis SpringBoot2.0 数据库读写分离
    JAVA 解密pkcs7(smime.p7m)加密内容 ,公钥:.crt 私钥:.pem 使用Bouncy Castle生成数字签名、数字信封
    mysql 创建函数或者存储过程,定义变量报错
    zabbix server源码安装
    zabbix准备:php安装
  • 原文地址:https://www.cnblogs.com/xxbiao/p/11485851.html
Copyright © 2020-2023  润新知