开端

好的,起因是我把最近做好的项目给一部分人进行了测试,发现大部分朋友都提出了同一个问题,你的系统权限管理是如何实现的。我只能尴尬的说一句,不好意思这部分还没开发。然而我也知道,其实对于一个项目来说,权限可以说是最主要的一部分。后端除了对权限进行处理,其实也就是提供一些业务逻辑对 CRUD 进行组合拼装。所以我打算接下来学习权限控制方面的知识并整合到我的项目中去,顺便把我的学习笔记分享给大家。

而对于权限控制的框架呢,听的最多的还是 Shiro 还有 Spring Security。Spring 的安全框架单独用在 Spring 项目中是无可挑剔的,不管是功能上还是维护方面。但是考虑到 Shiro 是一个全能性的框架,可以用在各种场合,甚至非 Web 项目中,由于它的会话独立于容器,后期学习分布式和微服务的时候也比较方便使用。最重要的是 Spring 官网用的也是 Shiro 的框架。所以还是打算学习 Shiro。

由于这部分内容比较多,我也发现了前后端项目部署那篇文章接近 1w 字导致阅读的时候不是很舒服,所以我打算把这个内容分成几个部分:初识 Shiro、配置重点、整合技巧来讲述。这篇文章是关于 Shiro 该如何配置的,面向 Spring Boot,讲一些比较基础的内容。

深入Shiro

之前我们了解了一下 Shiro 的基本功能和架构,现在我们来看看它的工作流程。

工作流程

截屏2020-08-12 下午9.00.15

拦截

Shiro 特别重要的一块就是拦截,它通过过滤器拦截所有请求,根据配置决定哪些可以认证通过,哪些不用被拦截,还有哪些没有通过认证强制跳转。我们看一段配置代码,如何通过过滤器决定谁去谁留。

@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shiroFilter(org.apache.shiro.mgt.SecurityManager securityManager){
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        shiroFilterFactoryBean.setLoginUrl("/user/login");
        Map<String,String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/user/login","anon");
        filterChainDefinitionMap.put("/**", "corsAuthenticationFilter");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        Map<String, Filter> filterMap = new LinkedHashMap<>();
        filterMap.put("corsAuthenticationFilter", corsAuthenticationFilter());
        shiroFilterFactoryBean.setFilters(filterMap);
        return shiroFilterFactoryBean;
    }

代码非常简单,首先使用 @Bean 注释将这个方法交给 Spring 来管理,名字必须是 shiroFilter,这样才能和默认配置中的名字匹配,否则 Spring 会找不到这个过滤器。在这个方法中首先 new 一个过滤器工厂对象,将获取到的 securityManager 注入。之后设置一个登陆 url,告诉 Shiro,这个页面/接口是我的登陆页面,用户角色权限的获取也都在这里。

接着就是刚刚图中的 filterChainDefinition,我们定义一个 map 来存储不被拦截的页面。每一个键值对中 key 存储 url,value 存储拦截器的名字,拦截器主要是以下几个:

拦截器名称拦截器功能
anno不需要授权和登录,可以匿名访问
authc需要登录授权才能访问
logout退出成功之后可以自定义重定向
user需要登录授权或者登录后开启了记住我(remember me)就能访问
ssl通过https协议才能通过
roles角色拦截
restrest风格拦截
port端口拦截
perms权限拦截
noSessionCreation不创建会话连接器
authcBasic基本http身份验证拦截

比较常用的四个拦截器我使用加粗表示出来了,anno 一般是最先使用的,为什么说最先使用,因为拦截器拦截的顺序是按照你 map 中加入的键值对的顺序来的。按照第一次匹配优先原则,如果一开始就被拦截,那么之后如果符合条件也不会放行。有一些公开资源,尤其是登录接口,肯定是要设置 anno 匿名的。假如说连入口的登录功能都拦截,那用户还这么使用这个系统的其他功能呢?

authc 是指需要登录或授权才能访问,这个值的 key 一般都是 "/*" ,而且写在最后。表示除了上述设置的拦截规则,对于其他所有 url 全部拦截并且需要授权才能访问。而我这里没有这么写,而是自定义了一个 corsAuthenticationFilter。从后面的代码也可以看到我又定义了一个 filterMap* ,使用我自定义的一个过滤方式拦截其他所有请求。本来我也是采用 authc 的方式,但是由于前后端跨域的需要,必须要加上跨域请求头的一些参数,所以直接另写了一个过滤器类,并且在其中处理了验证通过和验证失败的处理方式。具体可见下一篇,SSM 整合 Shiro,这里不多赘述。

认证

那么拦截之后我们怎么样判断用户是什么角色,拥有哪些权限呢?这块其实我在上一篇中有所讲到,已经把源码拉出来解析过了。有需要的朋友可以再去回顾一下,我这里只讲它整个认证的流程。

  1. 获取当前的 Subject,调用 SecurityUtils.getSubject( )
  2. 测试当前的用户是否已经被认证,即是否已登陆,调用 Subject 的 isAuthenticated( )
  3. 若没有被认证,则把用户名和密码封装为 UsernamePasswordToken 对象
  4. 执行登陆:调用 Subject 的 login(AuthenticationToken) 方法
  5. 自定义 Realm 方法,从数据库中获取相应的记录,返回给 Shiro
  6. 由 Shiro 完成对密码的比对

回顾第一篇我们讲到的,第一步是从数据库中找到所有的用户。第三步是通过我们前端获取到的用户名和密码生成 token。第四步是利用生成的 token 参数传给 login 方法进行登录。登录时对用户角色和权限的验证采用了自定义 Reaml 中重写的两个方法 doGetAuthenticationInfo( ) 和 doGetAuthorizationInfo( ),还记得吧,挺容易搞混的。

机制

在 Shiro 中有许多我们曾经经常使用到的内容,比如缓存,还有一些我们不太了解的东西,比如盐值加密。我这里简要讲一讲这些机制。

MD5盐值加密

盐值加密是我以前没有听说过的知识,所以在我第一次整合 Shiro 的时候也没有加入这一部分的内容。

众所周知,用户名和密码是被保存在数据库中。可是一旦数据库发生了泄露,用户名和密码就都遭到了泄露。攻击者可以轻松的获取用户名和密码,进行操作。更大的危害是,由于现在需要注册的网站、app越来越多。用户名和密码很多时候都是相同的。一旦某处发生了泄露,则后果会慢慢的扩散。这些危害大家可以查询下近些年发生的一些安全事故,如Sony数据库泄露、网易数据库泄露、CSDN数据库泄露等,因此我们要对密码进行加密。

为何取名为盐,此盐非彼颜。盐是许许多多微小的颗粒组成的,他就像一个随机数,你猜不到有多少颗。当你给密码加了盐,再结合 MD5,就不那么容易被破解了。

盐值加密主要就是如何生成盐,我们一般使用唯一的数据来产生。比如说我的数据库中用户名是不可重复的,那么用户名就是唯一的(昵称不唯一),所以可以使用用户名生成盐。

ByteSource.Util.bytes(userName)

ByteSource 是 Shiro 提供的一个工具类,可以转换进制。其中还有一个静态子类 Util,里面的 bytes( ) 方法返回了一个 SimpleByteSource 对象,而这个对象又使用 CodecSupport 中的 toBytes( ) 方法对接收到的不同参数采取不同的加密方式。以下为源码:

protected byte[] toBytes(Object o) {
        if (o == null) {
            String msg = "Argument for byte conversion cannot be null.";
            throw new IllegalArgumentException(msg);
        } else if (o instanceof byte[]) {
            return (byte[])((byte[])o);
        } else if (o instanceof ByteSource) {
            return ((ByteSource)o).getBytes();
        } else if (o instanceof char[]) {
            return toBytes((char[])((char[])o));
        } else if (o instanceof String) {
            return toBytes((String)o);
        } else if (o instanceof File) {
            return this.toBytes((File)o);
        } else {
            return o instanceof InputStream ? this.toBytes((InputStream)o) : this.objectToBytes(o);
        }
    }

一般而言我们生成了盐值之后,会使用 MD5 将原密码与盐值拼接在一起,然后再次进行加密,之后的加密次数是可以设置的,理论上加密的次数越多越安全。因为我没搞过安全,不太了解这一方面。我觉得咱们要是做个项目练练手的话没有必要加这个内容。如果需要,加密两次也足够了。因为我的项目数据表中没有写盐值,所以没有尝试过存在数据库中,只单独测试了几个例子,也就不展开讲了。

多Realm验证

通常来讲一个项目可能会用到多个数据库,比如说用户的验证需要 MySQL 和 Oracle 两个数据库中的数据,那这里就涉及到了多 Realm 验证和验证策略的问题。

对于每一个 Realm 都要配置到 ModularRealmAuthenticator 类中。还记得我们在认证和上一篇的 Shiro 基本功能中讲到了两个 doGetAuthenticationInfo( ) 和 doGetAuthorizationInfo( ) 方法吗。这两个方法就不多说了,我们接着要复写 ModularRealmAuthenticator 类,主要是要重写两个 isPermitted( ) 方法和一个 hasRole( ) 方法。假设我们已经拥有了一个 AdminRealm 和一个 UserReal,其中分别重写了两个 doGet 方法。那么我们就要在 ModularRealm 中通过匹配判断应该实现哪一个验证器的策略。

public class CustomizedModularRealmAuthorizer extends ModularRealmAuthorizer {
    @Override
    public boolean isPermitted(PrincipalCollection principals, String permission) {
        assertRealmsConfigured();
        Set<String> realmNames = principals.getRealmNames();
        //获取realm的名字
        String realmName = realmNames.iterator().next();
        for (Realm realm : getRealms()) {
            if (!(realm instanceof Authorizer)) continue;
            //匹配名字
            if(realmName.equals("admin"))) {
                if (realm instanceof AdminRealm) {
                    return ((AdminRealm) realm).isPermitted(principals, permission);
                }
            }
            if(realmName.equals("user")) {
                if (realm instanceof UserRealm) {
                    return ((UserRealm) realm).isPermitted(principals, permission);
                }
            }
        }
        return false;
    }
    @Override
    public boolean isPermitted(PrincipalCollection principals, Permission permission) {
        // 省略,和上一个方法一样
    }
    @Override
    public boolean hasRole(PrincipalCollection principals, String roleIdentifier) {
        // 省略,和上一个方法一样
    }
}

接着在 ShiroConfiguration 中的 securityManager( ) 中加入你的多 Realm,就像这样:

@Bean
public DefaultWebSecurityManager securityManager(UserRealm customRealm, AdminRealm adminRealm, DefaultWebSessionManager sessionManager) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
              //======================设置多个Realm======================
        List<Realm> realms = new ArrayList<>();
        realms.add(customRealm);
        realms.add(adminRealm);
        securityManager.setRealms(realms);
        securityManager.setSessionManager(sessionManager);
        securityManager.setCacheManager(new RedisCacheManager());
        //====================多realm授权核心代码===================
        CustomizedModularRealmAuthorizer authorizer = new CustomizedModularRealmAuthorizer();
        authorizer.setRealms(realms);
        securityManager.setAuthorizer(authorizer);
        return securityManager;
    }

注意咯,不仅要把 Realm 放进 SecurityManager 中,还要将其放入 CustomizedModularRealmAuthorizer 中。这样相当于开启了多 Realm 模式,程序会在运行到 doAuthenticate( ) 方法时判断有多个 Realm,然后返回多 Realm 认证策略。

// 判断Realm个数
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);
}
// 施行多Realm策略
protected AuthenticationInfo doSingleRealmAuthentication(Realm realm, AuthenticationToken token) {
        if (!realm.supports(token)) {
            String msg = "Realm [" + realm + "] does not support authentication token [" + token + "].  Please ensure that the appropriate Realm implementation is configured correctly or that the realm accepts AuthenticationTokens of this type.";
            throw new UnsupportedTokenException(msg);
        } else {
            AuthenticationInfo info = realm.getAuthenticationInfo(token);
            if (info == null) {
                String msg = "Realm [" + realm + "] was unable to find account data for the submitted AuthenticationToken [" + token + "].";
                throw new UnknownAccountException(msg);
            } else {
                return info;
            }
        }
}

认证策略

ModularRealmAuthorizer 有三种认证策略:

名称策略
FirstSuccessfulStrategy只要有一个Ream验证成功即可,返回第一个验证成功的认证信息而忽略其他
AtLeastOneSuccessfulStrategy只要有一个Ream验证成功即可,返回所有认证成功的验证信息
AllSuccessfulStrategy所有Realm都认证成功才算成功,且返回所有认证成功的验证信息

之前没有讲这个,因为 ModularRealmAuthorizer 默认实现第二种认证策略,所以只要有一个认证成功即可。这块的配置我只能查到使用 ini 或 xml 的形式,百度根本没有 Spring Boot 的配置方式,如果有小伙伴会直接在 ShiroConfig 类中修改的话请评论告诉我谢谢。下面是我找到的源码,不过还是不太能懂他具体怎么使用这个配置的。

public class AllSuccessfulStrategyTest {
    private AllSuccessfulStrategy strategy;
    @Before
    public void setUp() {
        strategy = new AllSuccessfulStrategy();
    }
    @Test
    public void beforeAllAttempts() {
        AuthenticationInfo info = strategy.beforeAllAttempts(null, null);
        assertNotNull(info);
    }
    @Test
    public void beforeAttemptSupportingToken() {
        new SimpleAccountRealm();
    }
    @Test(expected = UnsupportedTokenException.class)
    public void beforeAttemptRealmDoesntSupportToken() {
        Realm notSupportingRealm = new AuthorizingRealm() {
            public boolean supports(AuthenticationToken token) {
                return false;
            }
            protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
                return null;
            }
            protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principal) {
                return null;
            }
        };
        strategy.beforeAttempt(notSupportingRealm, null, null);
    }
}

权限注解

Shiro 中权限的配置有多种方式,其中编程式不太使用,因为需要写很多代码。老式写法一般都是 xml,我的话采用注解来完成。

<!--xml形式-->
<property name="filterChainDefinitions">
        <value>
            /login = anno
            /logout = logout
            /user = roles[user]
            /admin = permissions[admin:check]
        </value>
</property>
// 注解形式
@RequiresRoles("user")
@RequiresPermissions("admin:check")
@GetMapping(value = "/selectAll")
@ResponseBody
public Response<List<User>> selectAll() {
        List<User> result = userService.selectAll();
        if (result.size() != 0) {
            return getSuccessResult(result);
        }
        return getFailResult(404, "Message not found!");
    }

显然注解更加方便,xml 的话能够统一配置。特别注意一下,如果一个接口同时注解了 role 和 permission,必须要同时满足才可以访问。除此之外还有 @RequireUser@RequireAuthentication@RequireGuest 三种注解,分别表示需要登录或者记住我、必须登录、没有验证或记住我登录这三个条件,前两个相当于之前认证中讲到的 user、 authc。

标签

这部分内容是 jsp 中的标签,可以根据 shiro 的授权为不同角色显示不同信息或者输出权限信息等等,由于是涉及 jsp 的,我这里不展开讲了,今后的趋势是前后端分离,这些老旧的技术咱不讨论。虽然说这个机制确实挺方便的,但是熟悉前端的同学分分钟就能自己写 js 实现这些功能。

会话管理

在 Shiro 中有和 Java Web 想类似的会话管理。Shiro 中的会话是不需要基于容器的,而且可以在 Service 层跑,这是两个比较方便的特性。

记住我

你是否有印象,当访问一下网站登录时,下方会有一个记住我的选项框,如果勾选上之后并登录,第二次打开(一段时间之内)就不需要进行登录了。但是访问一些敏感网页,比如订单,还是需要重新登录以免他人借用你的电脑。事实上由于浏览器提供的密码保存功能,重新登录确实没有必要。

所以我们需要记住一点,我们登录了一个系统,那我们要么就是通过身份验证登录的,要么就是通过记住我登录的,两者只取其一。对于记住我的设置也非常简单,只需要在用户的 Subject 采用 login 方法前设置即可,上代码。

@PostMapping(value = "/login")
@ResponseBody
public Response<User> userLogin(@RequestBody User sysUser) {
        User result;
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken token = new UsernamePasswordToken(sysUser.getUsrName(), sysUser.getUsrPassword());
        token.setRememberMe(true);
        try {
            subject.login(token);
            result = userService.userLogin(sysUser.getUsrName(), sysUser.getUsrPassword());
        } catch (UnknownAccountException e) {
            return getFailResult(404, "Message not found");
        } catch (IncorrectCredentialsException e) {
            return getFailResult(412, "Incorrect message");
        } catch (LockedAccountException e) {
            return getFailResult(401, "Account locked");
        } catch (Exception e) {
            e.printStackTrace();
            return getFailResult(408, "Unknown error");
        }
        return getSuccessResult(result);
    }

注意一下我是直接设置为 true,如果小伙伴们希望通过用户在登录时勾选记住我才实现记住我的功能,需要在接口接受参数然后设置。除此之外还要在 ShiroConfiguration 中设置一下一些参数:

@Bean
public SimpleCookie rememberMeCookie(){
    // 取名为rememberMe
    SimpleCookie simpleCookie = new SimpleCookie("rememberMe");
    simpleCookie.setMaxAge(36000);
    simpleCookie.setHttpOnly(false);
    return simpleCookie;
}
@Bean
public CookieRememberMeManager rememberMeManager(){
    CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
    // 管理Cookie
    cookieRememberMeManager.setCookie(rememberMeCookie());
    cookieRememberMeManager.setCipherKey(Base64.decode("2AvVhdsgUs0FSA3SDFAdag=="));
    return cookieRememberMeManager;
}
@Bean
public DefaultWebSecurityManager securityManager(){
    DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
    securityManager.setRealm(myShiroRealm());
    securityManager.setCacheManager(cacheManager());
    // 设置记住我
    securityManager.setRememberMeManager(rememberMeManager());
    securityManager.setSessionManager(sessionManager());
    return securityManager;
}

设置 Cookie 的寿命为 36000 秒(10小时),设置 HTTPOnly 为 false,这一步很重要,否则前端无法读取 Cookie 数据。然后将其将入 rememberMeManager 里,顺带加个密。最后的最后将其注入 SecurityManager 中就完成了配置。

总结

经过前一篇的 Shiro 入门,大家已经初步了解了 Shiro 这个框架的基本功能和架构。在这篇进阶版教程中,更加详细的讲了拦截器和认证规则,以及许多我们项目中需要手动进行配置的内容,比如密码加密、权限注解、记住我功能等等。在下一篇 Shiro 系列的文章中,我会利用之前讲过的知识,教大家如何快速整合 Shiro、SSM 和 Vue 项目。如果在阅读的过程中大家觉得我有哪些地方描述的不正确,或者你能够解决文章中我提到的问题,欢迎评论交流!全部原创,真情实感,更新不易,需要动力 :)

最后修改:2022 年 05 月 27 日
随意