spring整合shiro系列 (四) spring boot shiro 整合(新版)

目录

 

1.依赖介绍

2.组件介绍

3.SecurityManager组件和Realm组件设计

3.1SecurityManager组件

3.2Realm设计

3.3SessionManager组件 

3.5权限注解激活

3.6权限管理 

4.前后端分离问题

4.1跨域

4.2Options请求

4.3未认证跳转问题

4.4Session不一致


1.依赖介绍

之前写了两篇有关shiro和spring的文章,结合目前java开发spring boot框架是大势,所以有必要将shiro和spring boot进行一次整合。我在使用spring  boot的时候比较明显的一个感触就是原来spring的xml配置后面都使用java bean的形式替代了。总体看java代码的程度更加纯粹了。首先是spring和shiro整合所需要的一些依赖

        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring-boot-web-starter</artifactId>
            <version>1.6.0</version>
        </dependency>
        <dependency>
            <groupId>org.crazycake</groupId>
            <artifactId>shiro-redis</artifactId>
            <version>3.3.1</version>
        </dependency>

这两个一个是spring boot整合shiro的一个web依赖,另一个是提供shiro缓存的依赖(也是网上大家用的比较多的一个)。别的就是web开发中常用的依赖加上就可以了。这里需要注意一下版本,因为开发的时候我用的版本是比较高的,和之前用低版本开发会不一样遇到了一些坑。

2.组件介绍

shiro主要由这些组件构成,这些组件能够支撑一个最小权限系统。在外部我们引用redis来做shiro的缓存载体。

首先通过IDE创建一个spring boot工程,在pom.xml文件中加入所需要的依赖。然后创建一个shiro的包(便于管理把shiro相关的配置放在包中)。第一个配置创建一个ShiroConfiguration类,这个类就是总的shiro的配置管理,前面图中的组件在这个类中被配置。

总的配置组件结构图如下

在configuration类创建一个shiroFilterFactoryBean然后注入安全管理器SecurityManager,创建一个linkedHashMap(有序),里面配置路由的规则,规定哪条路由匹配到哪个过滤器中。如果是前后端不分离的项目,一般可以使用shiro提供的默认过滤器,比较常用的就是anon(匿名访问),auth(登录授权访问)下面开始是将一些静态资源做过滤,比如用swagger做接口文档的,就需要把swagger的几条路由匹配到anon,而需要保护的资源就匹配到auth过滤器上

@Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager){
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setLoginUrl("/login.html");
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        LinkedHashMap<String, String> filterChainMap  = new LinkedHashMap<String, String>();
        filterChainMap.put("/swagger-ui/**","anon");
        filterChainMap.put("/webjars/**","anon");
        filterChainMap.put("/swagger-resources/**","anon");
        filterChainMap.put("/v3/**","anon");
        filterChainMap.put("/user/login","anon");
        filterChainMap.put("/**","auth");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainMap);
        return shiroFilterFactoryBean;
    }

3.SecurityManager组件和Realm组件设计

3.1SecurityManager组件

配置完shiroFilterFactoryBean后,我们需要配置securityManager安全管理器,按照之前的结构图,里面会设置三个组件

1、用来实现登录和授权的realm;2、用来管理会话维持登录状态的sessionManager;3、缓存实现的管理器CacheManager.

@Bean
    public DefaultWebSecurityManager securityManager(SessionManager sessionManager,RedisCacheManager shiroRedisCacheManager,
                                                     SelfRealm selfRealm){
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(selfRealm);
        securityManager.setSessionManager(sessionManager);
        securityManager.setCacheManager(shiroRedisCacheManager);
        return securityManager;
    }

3.2Realm设计

然后来依次配置这三个组件,首先是Realm,创建一个Java class然后继承AuthorizingRealm,重写父类中的doGetAuthenticationInfo和doGetAuthorizationInfo方法。这两个方法分别是实现对用户登录的验证和校验用户的权限的。

 @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        String principal = authenticationToken.getPrincipal().toString();
        ShiroUserPO user = shiroUserDao.getUserByName(principal);
        if (null == user){
            throw new UnknownAccountException();
        }
        if (user.getLocked()){
            throw new LockedAccountException();
        }
        SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(user,user.getUserPassword(),getName());
        return authenticationInfo;
    }

这里有一个AuthenticationToken 参数,这个参数是个接口,我们接受的默认是它的UsernamePasswordToken实现。这个实现中可以获取用户登录时传递的账号和密码。通过账号我们可以查询出这个的账号信息,这个方法需要返回一个AuthenticationInfo类型的数据,这个也是一个接口,默认的实现是SimpleAuthenticationInfo。这里需要和我们定义的加密算法组件联系起来。在系统中我们选择加盐加密的方式来对密码进行编码保存。

    @Bean
    public SelfRealm selfRealm(){
        SelfRealm selfRealm = new SelfRealm();
        selfRealm.setCredentialsMatcher(passwordMatcher());
        return selfRealm;
    }
   @Bean
    public PasswordMatcher passwordMatcher(){
        PasswordMatcher passwordMatcher = new PasswordMatcher();
        return passwordMatcher;
    }

将前面创建的realm交给spring容器管理。配置它的凭证匹配器,这个匹配器这里使用shiro包中提供的新的加密类。之前旧版本里面匹配器大多是这样实现的,实例化一个哈希凭证匹配器,定义算法和迭代次数。

    @Bean
    public HashedCredentialsMatcher hashedCredentialsMatcher(){
        HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
        hashedCredentialsMatcher.setHashAlgorithmName(Md5Hash.ALGORITHM_NAME);
        hashedCredentialsMatcher.setHashIterations(2);
        return hashedCredentialsMatcher;
    }

这里我们使用默认的passwordMathcer,它采用的是SHA-256算法,迭代500000,加盐加密。到这里登录验证的配置基本完成。源码上shiro大概是这样的实现流程

Subject currentUser = SecurityUtils.getSubject();

第一步通过上下文得到一个主体对象,用于保存用户信息和会话信息

private void shiroLogin(LoginParam loginParam, Subject currentUser){
        UsernamePasswordToken token = new UsernamePasswordToken(loginParam.getUserName(),loginParam.getPassword());
        try {
            currentUser.login(token);
        }catch (UnknownAccountException ua){
            ErrorLogUtil.errorLog(ua);
            throw new ServiceErrorException(ServiceErrorEnum.UN_KNOWN_ACCOUNT);
        }catch (IncorrectCredentialsException ice){
            ErrorLogUtil.errorLog(ice);
            throw new ServiceErrorException(ServiceErrorEnum.ERROR_CREDENTIALS);
        }catch (LockedAccountException le){
            ErrorLogUtil.errorLog(le);
            throw new ServiceErrorException(ServiceErrorEnum.LOCK_ACCOUNT);
        }catch (AuthenticationException ae){
            ErrorLogUtil.errorLog(ae);
            throw new ServiceErrorException(ServiceErrorEnum.AUTH_ERROR);
        }
    }

第二步将前台传递的账号和密码封装到UernamePasswordToken对象中,并调用Subject的login方法。这个Suject由DelegatingSubject实现其login方法,我们截取片段,这里是又是调用这个安全管理器SecurityManager的login方法。而这个管理器组件在之前的配置类中我们已经配置过了,它由DefaultSecurityManager来实现login方法

public void login(AuthenticationToken token) throws AuthenticationException {
        this.clearRunAsIdentitiesInternal();
        Subject subject = this.securityManager.login(this, token);
        String host = null;
        PrincipalCollection principals;
        if (subject instanceof DelegatingSubject) {
            DelegatingSubject delegating = (DelegatingSubject)subject;
            principals = delegating.principals;
            host = delegating.host;
        } else {
            principals = subject.getPrincipals();
        }

        if (principals != null && !principals.isEmpty()) {
            this.principals = principals;
            this.authenticated = true;
            if (token instanceof HostAuthenticationToken) {
                host = ((HostAuthenticationToken)token).getHost();
            }

在DefaultSecurityManager中声明了AuthenticationInfo用于保存用户的信息,这里的authenticate方法会调用AuthenticatingSecurityManager这个抽象类中的方法,

 public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
        AuthenticationInfo info;
        try {
            info = this.authenticate(token);
        } catch (AuthenticationException var7) {
            AuthenticationException ae = var7;

而AuthenticatingSecurityManager会调用Authenticator接口的实现类AbstractAuthenticator。这个实现类本身是个抽象类,所声明的这个doAuthenticate方法也是抽象方法。所以要继续看它的实现类ModularRealmAuthenticator

 protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
        this.assertRealmsConfigured();
        Collection<Realm> realms = this.getRealms();
        return realms.size() == 1 ? this.doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken) : this.doMultiRealmAuthentication(realms, authenticationToken);
    }

可以看到这里会获取我们在之前securityManager中配置的所有realm。如果你配置了单个realm那就直接执行这个realm的登录校验。如果是多realm,那么会遍历这个realm组,把支持你传入的这个令牌token类型的realm都执行一遍它们的登录校验实现,然后将得到的AuthenticationInfo 合并起来,可以简单认为是将各个凭证放到集合中。这里先只考虑单个realm的实现

接着会调用到抽象类AuthenticatingRealm,之前我们自定义的realm就是继承它。doGetAuthenticationInfo就是获取账号信息的实现,而assertCredentialsMatch就是密码的校验,这里shiro会去读取配置类中我们设置凭证匹配器来选择对应的实现。到这里登录的认证就结束了

public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        AuthenticationInfo info = this.getCachedAuthenticationInfo(token);
        if (info == null) {
            info = this.doGetAuthenticationInfo(token);
            log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info);
            if (token != null && info != null) {
                this.cacheAuthenticationInfoIfPossible(token, info);
            }
        } else {
            log.debug("Using cached authentication info [{}] to perform credentials matching.", info);
        }

        if (info != null) {
            this.assertCredentialsMatch(token, info);
        } else {
            log.debug("No AuthenticationInfo found for submitted AuthenticationToken [{}].  Returning null.", token);
        }

        return info;
    }

3.3SessionManager组件 

 然后shiro是如何维持这个会话的呢?这里依赖于sessionManager这个组件,默认这个会话是保存在服务器的内存中的,但是我们可以通过修改sessionDao来更改它的存储方式。这里使用大家比较熟悉的redis来存储。首先在配置类中声明一个redis的底层容器,配置了redis的主机端口和密码。

 @Bean
    public RedisManager redisManager(@Value("${spring.redis.host}") String host,
                                     @Value("${spring.redis.port}") String port,
                                     @Value("${spring.redis.password}") String password){
        RedisManager redisManager = new RedisManager();
        redisManager.setHost(host+":"+port);
        redisManager.setPassword(password);
        return redisManager;
    }

然后以这个redisManager为基础来配置两个redis的组件。一个是shiro的session存储实现redisSessionDao,另一个是shiro的缓存实现redisCacheManager。


    @Bean("shiroRedisCacheManager")
    public RedisCacheManager shiroRedisCacheManager(RedisManager redisManager){
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager);
        //重写凭证的缓存key字段名
        redisCacheManager.setPrincipalIdFieldName("userId");
        return redisCacheManager;
    }

    @Bean
    public RedisSessionDAO redisSessionDAO(RedisManager redisManager){
        RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
        redisSessionDAO.setRedisManager(redisManager);
        return redisSessionDAO;
    }

然后将这两个组件配置到sessionManager中

 @Bean
    public SessionManager sessionManager(RedisSessionDAO redisSessionDAO,RedisCacheManager shiroRedisCacheManager){
        SelfSessionManager sessionManager = new SelfSessionManager();
        sessionManager.setSessionDAO(redisSessionDAO);
        sessionManager.setCacheManager(shiroRedisCacheManager);
        return sessionManager;
    }

redisSessionDao默认的缓存设计是以shiro:session作为key的前缀,用sessionId作为key来保存session信息。默认的过期策略是将redisSession的过期时间设置和shiroSession的一致。开发者可以修改过期策略自定义redisSession的过期时间。当redisSession的过期时间小于shiroSession是程序会打印警告日志。

3.5权限注解激活

shiro除了登录校验和会话管理之外比较重要的就是权限的管理了。其中获取账号的权限就是由之前的realm来实现。一般我们会使用注解来检验权限。所以在配置类中要激活权限

  @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }

在低版本的shiro中激活权限注解使用,,只要配置上面的这容器就可以了。但是在高版本的shiro中存在问题,只使用上面的配置,权限注解不能正常使用,同时使用swagger后所有加上权限注解的接口在文档中都无法显示。网上给出的结论是和spring的aop有冲突,需要增加下面配置

    @Bean
    @DependsOn("lifecycleBeanPostProcessor")
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        defaultAdvisorAutoProxyCreator.setUsePrefix(true);
        return defaultAdvisorAutoProxyCreator;
    }

3.6权限管理 

在realm中实现doGetAuthorizationInfo方法,主要的操作就是将账号对应存储的角色和权限查询出来,赋值到AuthorizationInfo上。用到角色注解的就查询并赋值角色信息,用到权限注解的就查询并赋值权限信息。

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        ShiroUserPO shiroUserPO = (ShiroUserPO) principalCollection.getPrimaryPrincipal();
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        List<RolePO> rolePOList = shiroUserDao.getUserRoleByName(shiroUserPO.getUserName());
        Set<String> roleSet = rolePOList.stream().map(RolePO::getRoleName).collect(Collectors.toSet());
        simpleAuthorizationInfo.setRoles(roleSet);
        if (CollectionUtils.isEmpty(rolePOList)){
            return simpleAuthorizationInfo;
        }
        List<PermissionPO> permissionPOList = roleDao.getPermissionByRoleIdS(rolePOList.stream()
                .map(RolePO::getRoleId).collect(Collectors.toList()));
        Set<String> permissionSet = permissionPOList.stream().map(PermissionPO::getPermissionName).collect(Collectors.toSet());
        simpleAuthorizationInfo.setStringPermissions(permissionSet);
        return simpleAuthorizationInfo;
    }

那么这个权限注解是如何工作的?首先在方法上添加@RequiresPermissions。那么请求的时候就会被拦截,然后通过判断是角色注解还是权限注解而进入不同的handler处理器,例如权限的。shiro会首先获取当前的账号,同时解析注解上的权限字段。然后调用checkPermission方法去检验。

  public void assertAuthorized(Annotation a) throws AuthorizationException {
        if (a instanceof RequiresPermissions) {
            RequiresPermissions rpAnnotation = (RequiresPermissions)a;
            String[] perms = this.getAnnotationValue(a);
            Subject subject = this.getSubject();
            if (perms.length == 1) {
                subject.checkPermission(perms[0]);
            } else if (Logical.AND.equals(rpAnnotation.logical())) {
                this.getSubject().checkPermissions(perms);
            } else {
                if (Logical.OR.equals(rpAnnotation.logical())) {

这个方法主要有两个步骤,一是检验账号,判断账号是否有效,是否还保持登录态,如果不是直接抛出未登录的异常。通过一的步骤后,在执行checkPermission,这个方法是AuthorizingRealm中的,这里要做的就是获取账号在realm中的权限实现。但是一个权限系统大多的资源都是受保护的,如果每个请求都需要去查询账号的权限的话,那是很浪费性能的,所以shiro会先从缓存中获取权限信息如果没有再会向去做查询然后,缓存这部分权限信息。这里缓存由redis实现,这个shiro-redis的依赖中默认缓存前缀shiro:cache,拼接上shiro中配置的realm的获取权限信息方法的方法全名,再拼接上后缀.authorizationCache。然后跟上我们的key,这个key用来区分是那个账号,这个key和之前配置类中redisCacheManager中

配置的PrincipalIdFieldName有关,这里会通过这个字段名获取key,所以这个字段要存在唯一性一般用账号id,缓存的过期时间默认是30分钟,可以自定义。至于权限的比对就是简单的list遍历比较了。到这里权限的验证也基本结束了。

4.前后端分离问题

上面基本就是shiro的主要功能了,在实际开发中大多是前后端分离,会遇到一些跨域,session不一致等问题,总结了下主要可以这样解决。

4.1跨域

一跨域,可以配置跨域的过滤器,不过注意这个跨域的过滤器不用配置到shiro的自定义过滤器里面,只要单独交给spring管理就可以了,声明一个过滤器实现filter,主要是设置header。

Access-Control-Allow-Credentials设置true允许跨域携带cookie时,Access-Control-Allow-Origin不能设置为*,所以一般可以设为request的origin。但是有时候,由于浏览器策略原因,(谷歌浏览器现在似乎对跨域cookie非常严格,主要是SameSite属性,这里不展开了),所以普遍的方法是使用token,因为token是无状态的,比较灵活。注意如果用了自定义token,并且携带在请求头上,那么在Access-Control-Allow-Headers要加上你的自定义请求头。

public class CorsFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.debug("corsFilter init");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = WebUtils.toHttp(servletRequest);
        HttpServletResponse response = WebUtils.toHttp(servletResponse);
        setHeader(request, response);
        filterChain.doFilter(request, response);
    }

    @Override
    public void destroy() {
        log.debug("corsFilter destroy");
    }

    private void setHeader(HttpServletRequest request, HttpServletResponse response) {
        response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
        //需要包含自定义的token
        response.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept,Authorization,If-Modified-Since, auth-token");
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Credentials", "true");
    }

4.2Options请求

二options请求。由于跨域导致浏览器发送一次options预检请求,当访问的是受保护的资源时就会有问题,那么我们需要对这个请求做放行。所以一般是重写授权过滤器。自定义一个过滤器继承FormAuthenticationFilter,其中preHandle方法如果返回true则会执行下一个过滤器,如果返回false则直接返回。所以判断如果是options方法就返回false同时设置状态为200

@Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest servletRequest = WebUtils.toHttp(request);
        HttpServletResponse servletResponse = WebUtils.toHttp(response);
        CorsFilter.setHeader(servletRequest,servletResponse);
        if (servletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            servletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }

另一个是isAccessAllowed方法,这个方法在父类中是判断当前的url是不是登录url以及账号当前的登录态。为了放行options请求,这里对于请求方法是options的请求直接返回true,其它方法执行父类的实现。

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        if (request instanceof HttpServletRequest &&
                WebUtils.toHttp(request).getMethod().equals(HttpMethod.OPTIONS.name())){
            return true;
        }
        return super.isAccessAllowed(request, response, mappedValue);
    }

4.3未认证跳转问题

三未认证,shiro默认会让没有登录认证的账号跳转到登录页。但是在前后端分离的项目中,这个跳转应该让前端来完成,所以后端只需要返回一个特定的响应码就可以了

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest servletRequest = WebUtils.toHttp(request);
        HttpServletResponse servletResponse = WebUtils.toHttp(response);
        servletResponse.setHeader("Access-Control-Allow-Origin", servletRequest.getHeader("Origin"));
        servletResponse.setHeader("Access-Control-Allow-Credentials", "true");
        servletResponse.setStatus(HttpStatus.OK.value());
        servletResponse.setCharacterEncoding("UTF-8");
        servletResponse.setContentType("application/json");
        PrintWriter printWriter = servletResponse.getWriter();
        WebResponse<Object> fail = WebResponse.fail(ServiceErrorEnum.NEED_LOGIN.getErrCode(),
                ServiceErrorEnum.NEED_LOGIN.getErrMsg());
        printWriter.write(JSON.toJSONString(fail));
        printWriter.close();
        return false;
    }

二和三的实现写在自定义的认证过滤器中,这个过滤器需要在之前的shiroFilterFactoryBean,以自定义过滤器的身份配置进去,直接以new 的方式注入,不需要声明成spring bean。同时要替换掉原来过滤器的“auth”

4.4Session不一致

四session不一致,由于跨域,导致前端每次请求的时候sessionId都会变化,无法辨识账号的登录态。所以我们需要重写shiro的sessionManager,我们可以在第一次登录成功后将sessionId返回给前端,然后让前端在之后的请求中将sessionId放在自定义请求头上。我们创建一个名为SelfSessionManager的类继承DefaultWebSessionManager。

    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        String sessionId = WebUtils.toHttp(request).getHeader(TOKEN);
        if (StringUtils.isNotBlank(sessionId)) {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
            return sessionId;
        } else {
            return super.getSessionId(request, response);
        }
    }

重写getSessionId的方法,首先判断token中是否存在这个值,如果有就把token里的值作为sessionId返回;如果没有,就调用父类的方法。这样等于利用了token将sessionId保持不变了。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值