Shiro安全框架(一)基本概念以及与SpringBoot的整合

Posted by Lain on 10-11,2019

Apache Shiro 是一款运用广泛的Java安全框架,特点是使用起来比较简单,Api比较人性化。最近要用到这玩意,提前复习一下相关概念。(下文Shiro的核心组件部分摘自张开涛/著 《跟我学Shiro》)

1、Shiro的核心组件

Subject: 在Shiro中,Subject是对外API的核心,他代表着 当前用户 这一概念,这个用户并不只是一个具体的人,与当前应用交互的,比如爬虫,机器人,接口调用等,是一个抽象的概念,所有的Subject都绑定到了SecurityManager,可以认为Subject是一个门面,SecurityManager才是实际的执行者。

打开Subject的接口方法列表,可以看到里面封装了大量的鉴权操作。

SecurityManager:安全管理器,可以看作是Shiro的核心,所有与安全相关的操作都会与他进行交互;而且他管理着所有Subject,同时负责与后面介绍的其他组件进行交互,可以看出它是Shiro的核心。

Realm Shiro从Realm获取安全数据(如用户、角色、权限),就是说SecurityManager要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法,也需要从 Realm 得到用户相应的角色/权限进行验证用户是否能进行操作;可以把 Realm 看成 DataSource,即安全数据源。

也就是说对于我们而言,最简单的一个 Shiro 应用:

1、 应用代码通过 Subject 来进行认证和授权,而 Subject 又委托给 SecurityManager;

2、 我们需要给 Shiro 的 SecurityManager 注入 Realm,从而让 SecurityManager 能得到合法的用户及其权限进行判断。

从以上也可以看出,Shiro不提供维护用户权限,而是通过Realm让开发人员自己注入。

而在Shiro与SpringBoot的整合中,实现一个简单的鉴权,也是实现自定义的Realm,将其注入。

2、与SpringBoot整合

Shiro官方为SpringBoot提供了官方的starter,如下引入:

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-starter</artifactId>
    <version>1.4.0</version>
</dependency>

我们先来自定义一个Realm。
首先新建一个类,然后继承 AuthorizingRealm
AuthorizingRealm是一个抽象类,它要求实现两个方法:

public class MyRealm extends AuthorizingRealm {

    /**
     * 用于角色权限验证
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
	return null;
    }

    /**
     * 用于登录权限验证
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        return null;
    }
}

doGetAuthenticationInfo 用于登录权限验证,当用户登录系统时,会调用这个方法,然后返回一个AuthenticationInfo的实现,AuthenticationInfo实际上是对用户登录信息的一个封装,我们来看具体实现:

    /**
     * 用于登录权限验证
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        String  userName= (String)authenticationToken.getPrincipal();
        UserEntity user = userDao.selectOne(new QueryWrapper<UserEntity>().eq("name",userName));
        //查询用户的角色
        if(user == null){
            throw new UnknownAccountException("用户不存在!");
        }
        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user,user.getPassword(),getName());
        return info;
    }

然后再看一个简单的通过用户名和密码来登录的接口:

    @RequestMapping("/login")
    public String login(String name,String password){
        UsernamePasswordToken token = new UsernamePasswordToken(name,password);
        SecurityUtils.getSubject().login(token);
        return "登录成功!";
    }

我们可以看到,用户通过用户名密码登录的时候,我们将其封装成了Shiro帮我们实现的UsernamePasswordToken ,然后从SecurityUtils里获取了Subject对象,前面我们说过,Subject对象实际上就是SecurityManager的对外API,在调用login方法的时候,会委托SecurityManager行事,然后我们在自定义的Realm的doGetAuthenticationInfo方法里面,就能接收到这个token,通过getPrincipal()方法就能获取到用户登录时输入的用户名,getCredentials()就能获取密码。
然后我们从数据库里查询出用户的信息,取出密码,然后封装成SimpleAuthenticationInfo,Shiro会帮我们判断密码是否匹配。
值得注意的是,这里传入了一个User,其实只是作为一个唯一凭证,它可以是用户的邮箱,UUID,手机号等任意数据,也可以是Shiro官方定义的接口 PrincipalCollection 的实现。之所以这么做,是因为能标识用户身份的往往有多个,比如手机号和邮箱,以及用户Id,我们可以把它们都封装进PrincipalCollection 里,但必须要指定其中一个作为基础的标识。

在用户登录验证成功之后,访问需要权限判断的接口时,就会通过 doGetAuthorizationInfo来校验用户是否有权限访问。这里给出一个简单的实现:

    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        UserEntity user = (UserEntity) SecurityUtils.getSubject().getPrincipal();
        List<Long> relRoleList= userRelRoleDao.selectList(new QueryWrapper<UserRelRole>().eq("user_id",user.getId())).stream().map(UserRelRole::getRoleId).collect(Collectors.toList());
        List<RoleEntity> roleEntities = roleDao.selectBatchIds(relRoleList);
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        // 角色
        Set<String> roles = new HashSet<>();
        Set<String> permissions = new HashSet<>();
        for(RoleEntity role:roleEntities){
            roles.add(role.getRoleName());
            permissions.add(role.getPerm());
        }
        authorizationInfo.setRoles(roles);
        authorizationInfo.setStringPermissions(permissions);
        return authorizationInfo;
    }

具体实现就不用看了,我为了偷懒,直接通过Mybatis-plus的QuerryWapper模式做了个假联表查询。这个方法里主要就是获取用户的权限信息。我们可以看到这里直接从SecurityUtils里获取Subject,getPrincipal,然后直接转换成了User对象,没有一丝犹豫。没错,这就是我们刚刚在登录验证的时候放进去的User对象,所以说不管是什么都行,只要能具体表示到某个用户身上。我们通过它查询到用户所拥有的角色,以及角色拥有的全部权限,把它们统统丢进了HashSet里,返回一个authorizationInfo。
这里每次用户请求权限的时候都会查权限表,实际上是不妥的,权限不是频繁变动的东西,没必要每次都查询,明天再来研究如何对Realm作缓存,Shiro对此也有默认实现。

我们在数据库里配置权限表,设计如下:

0

用户可以被分配多种角色,而每个角色允许拥有多个权限。在web开发中,这些权限都是对接口的访问权,因此我们用接口路径来作为它的权限标识。
Shiro提供了 @RequiresPermissions@RequiresRoles等注解,可以很方便的对接口的访问权限做限制,但值得一提的是,Shiro的注解是通过AOP实现的,因此要引入Spring-aop,否则注解将不会生效。

<dependency>
	<groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

然后写一个接口:

    @RequiresPermissions("api:user:get")
    @RequestMapping("/api/user/get")
    public String get(){
        return "Lain";
    }

这里用了@RequiresPermissions(),我们也可以根据自己的需求写其他注解。其实接口上无脑写请求路径就好,毕竟接口的路径是绝对唯一的,之后只要在角色对应的权限里配上这个perm,用户只要被分配了角色,那么就会必然拥有对于这个接口的权限了。
Shiro里role和permission并没有明确的上下级关系,你也可以只配role。

最后提一下Shiro的配置。
我们使用SpringBoot推荐的代码配置的方式。
创建一个类并打上@Configuration注解,下面直接上代码

@Configuration
public class ShiroConfig {

    @Bean
    public ShiroFilterFactoryBean shirFilter() {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        // 必须设置 SecurityManager
        shiroFilterFactoryBean.setSecurityManager(securityManager());
        shiroFilterFactoryBean.setLoginUrl("/login");
        // 设置无权限时跳转的 url;
        shiroFilterFactoryBean.setUnauthorizedUrl("/auth");

        Map<String,String> map = new HashMap<>();
           map.put("/api/user/put","perms[api:user:put]");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
        return shiroFilterFactoryBean;
    }


    @Bean
    public MyRealm myRealm(){
        return new MyRealm();
    }

    @Bean
    public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {

        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        defaultAdvisorAutoProxyCreator.setUsePrefix(true);

        return defaultAdvisorAutoProxyCreator;
    }

    @Bean
    public SessionsSecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(myRealm());
        return securityManager;
    }
}

注意,我们刚刚自定义的Realm里面是注入了Dao的,但我们并没有给MyRealm加上@Component等注解,因此Spring的包扫描不会将其扫描进容器。然后我们在Configuration里注入了它,

    @Bean
    public MyRealm myRealm(){
        return new MyRealm();
    }

这个Bean依旧会被自动装配,被容器托管,其余bean配置里要用到这个Bean的时候,只需要直接调用方法即可注入。
这里我们注入了securityManager和shirFilter,DefaultAdvisorAutoProxyCreator 是为了解决注解模式导致RequestMapping失效的问题。
这里要提一点,如果是使用注解方式,那么我们如果要实现诸如权限不够,跳转到某个页面,或者定制化提示信息,就只能捕获全局权限异常,然后自己去写实现,shiroFilterFactoryBean.setUnauthorizedUrl("/auth")是没用的,它只管通过url方式配置的过滤器,注解的管不着的。