简单安全管理框架——Shiro进阶

799 阅读14分钟

我正在参加「掘金·启航计划」

简单安全管理框架——Shiro进阶

写在前面

(1)本文摘要

  1. 为何要定制化Shiro
  2. 自定义 Realm
  3. 自定义 CredentialsMatcher
  4. 自定义 Token
  5. 自定义 Filter
  6. 将Shiro集成到 Web项目的具体步骤

(2)读前须知

  1. 本文是承接着上一篇Shiro的文章
  2. 上一篇Shiro的文章,操作都是对JavaSE的项目而言的。可我们怎么将Shiro集成到Web项目
  3. 并且定制化配置,自定义的内容如下
    • 过滤器(Filter)、数据源(Realm)
    • 校验规则(Token)、密码匹配规则(CredentialsMatcher)

一、目标实现

  • 现在前后端分离的项目中,有着各种各样的会话管理方案。而今较为常用的有Token + xxx
  • 我们想要用Shiro做认证授权,刚好可以和 Token结合起来,
  1. 登录接口:返回Token令牌
  2. 其他未放行接口:携带登录时返回的Token,需验证:
    • 请求是否携带Token
    • Token是否有效
    • 经过Shiro认证
    • 经过Shiro授权

二、为什么要自定义

  • 在具体配置之前,我们先来聊聊,为什么要定制化Shiro
  • 如果你耐心看完了上一篇文章的认证流程与授权流程,应该会发现几个问题
    • 登录传入的默认令牌Token,太过于局限
    • 默认数据源Realm太死板
    • 密码Credentials校验规则太随意
    • 需要手动去调用认证方法
    • ....
  • 那我们就来说说具体的意义吧

(1)自定义Realm

  • 相信现在说到这个,你已经明白为什么要自定义数据源了
  • 最主要的就是去重写两个方法doGetAuthenticationInfo | doGetAuthorizationInfo
  • 一个去获取用户信息,一个去获取角色权限信息
  • 如果还不清楚,可以回看一下上一篇的内容

(2)自定义Token

  • 在Web项目中,通常会使用 Token + ... 进行会话管理
  • 通常情况下,用户登录成功后会返回一个用户身份令牌(Token)
  • 用户访问其他功能时,都需要携带上这个令牌,代表自己是一个合法用户
  • 用户拿到了这个令牌,就相当于登录成功了,换句话说就是用户名和密码已经校验成功了
  • 这时就不需要再使用Shiro去验证用户名和密码了(冗余操作)
  • 那问题又来了啊,使用Shiro应该校验什么呢?
  • 答案很简单,校验用户身份令牌Token即可
  • Token代表用户身份,那么证明Token是有效的之后,不就可以知道用户是有效了的呐!
  • 如果我们想要利用类似这样的方式去进行认证,那么以前的简单比对用户名和密码,就不能满足我们的需求了
  • 所以我们也需要自定义校验规则Token,使我们的校验更加灵活

(3)自定义CredentialsMatcher

  • 如果你看完了上篇文章的认证流程,你就会知道,Shiro会在何时去进行密码匹配
  • 如果你看完了为什么要自定义Token,你应该也可以推测出,为什么要自定义密码匹配规则
  • (可是哪里会有那么多的如果)
  • 其实就是为了按我们自己的规则进行校验,甚至是直接放行,不用校验

(4)自定义Filter

  • 其实自定义过滤器Filter,完全可以不写在这篇文章里
  • 因为Filter是独立出来的一种技术,可以完全不依赖Shiro
  • 这时候你可能会骂我了:“什么小破文💩💩💩...balabala....”
  • 先别急,听我细细道来,耐心看下去
  • 一般情况下,前后端分离的Web项目,会有很多接口
  • 而这些接口,有的是不需要登录就可以访问的、需要登录才能访问的、需要有特定权限才能访问的
  • 所以,我们怎么去认证?何时去认证?何时去加载权限?
  • 不可能每个接口,都做一遍判断吧?
  • 这时候,聪明的你大概率会想到 过滤器、拦截器之类的词
  • 其实都可以实现,而我们这里选择使用过滤器Filter,可以和Shiro很好的结合起来
  • 所以我们总结一下为什么要自定义过滤器:
  • 为了过滤请求、可以检验用户是否携带身份令牌TokenToken是否过期,去加载用户的角色,去加载用户的权限...
  • 使用过滤器,可以做到书写一次验证代码,所有接口适用

三、具体实现

  • 说完了为什么要定制化Shiro,那我们就来定制化并且使用一下它吧~

Step0【导入依赖】

        <!-- 权限控制 -->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring-boot-web-starter</artifactId>
            <version>${shiro.version}</version>
        </dependency>
  • 这一步就不过多的描述了

Step1【Shiro 配置类】

  • 我们在使用 Shiro 之前,得先配置一下Shiro
  • 就好比你去读书之前,总要填写很多信息,才能进入学校
  • 那我们具体配置什么呢?在具体配置之前,不妨先和我一起看看,我们想要使用Shiro来做什么

image-20221008173049018

  • 如上图所示,我们就想要做到这种效果。
  • 请求从客户端发送过来,通过Shiro中断一下
  • 1、决定我们的请求是否能调用controller【认证】
  • 2、到达了controller,是否需要去鉴权【授权】
  • 所以,我们要去配置一个 Shiro过滤的工厂对象,并且将其放入SpringIoC容器中

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(Realm realm) { }
  • 在这个方法里,我们需要:
    • 告诉Shiro如何进行拦截
    • 拦截那些URL
    • 每个URL需要进行那些 filter
  • 如果你看完了之前的内容,你肯定知道Shiro是通过安全管理器来管理自己的一些列流程的
  • 那我们就需要告诉ShiroFilterFactoryBean,我们的安全管理器
  • 而设置安全管理器的时候,我们又需要告诉安全管理器,我们需要使用什么数据源
  • 而设置数据源的时候,我们又需要告诉数据源。使用什么校验规则、密码匹配规则

image-20221008175949144

  • 如上图所示,聪明的你,应该能猜到,我们 Setp2、3、4要做什么了吧~
  • shiroFilterFactoryBean()方法详细实现在下面奉上,因为我们要先自定义...💄

Step2【自定义Realm】


public class TokenRealm extends AuthorizingRealm {

    /**
     * 告诉此Realm需要使用什么密码匹配规则
     * @param matcher:自定义的密码匹配规则
     */
    public TokenRealm(TokenMatcher matcher) {
        super(matcher);
    }
    
    /**
     * 用于认证器所需要的Token
     * @param token : 认证器的那个token
     * @return :是否符合要求
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof Token;
    }

    /**
     * 授权器
     * @param principals :认证器认证成功传过来的shiro信息【Shiro的用户名和密码】
     * @return 该shiro用户所拥有的权限和角色信息
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        // 拿到当前登录用户的token
        String token = (String) principals.getPrimaryPrincipal();
        
 		SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        
        // TODO:1、根据token查找用户的角色、权限
        
        // TODO:2、添加角色
        info.addRole(/* 角色 */);

        // TODO:3、添加权限
        info.addStringPermission(/* 权限 */);
        
        return info;
    }

    /**
     * 认证器 【SecurityUtils.getSubject().login(new Token(token)) 会触发此认证器】
     * @param authenticationToken:token
     * @return :认证成功传出去的 信息【Shiro的用户名和密码】
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String tk = ((Token) token).getToken();
        return new SimpleAuthenticationInfo(tk, tk, getName());
    }
}
  • 相信你现在看到doGetAuthenticationInfo、doGetAuthorizationInfo方法,应该很清晰
  • 但是你可能也会有疑问,为什么我在doGetAuthenticationInfo方法里,直接返回了需要认证时传入的 token,并没有去验证用户名啥的
  • 这是为什么呢?我们暂且称为疑惑一,在解决疑惑一之前,我先说一下为什么要重写 supports()方法

supports()方法

  • 这个方法拿来干嘛的呢?为什么要重写该方法呢?
  • 看整个方法的构造和方法名
boolean supports(AuthenticationToken token);
  • 你大概能猜到,这是用来查看Realm支持什么 Token校验令牌的

image-20221010124316780

  • 从上图,我们可以知道,Shiro在去 认证(Authentication) 之前,会先检查一下,我们使用的数据源Realm,它的Token是否支持使用,如果不支持就会抛出异常【Realm不能使用此种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);
  • 而我们既然要自定义实现 Token,那么我们可以限定传入Token的类型,是我们自定义的Token
  • 当你知道这些,你也就知道,supports()方法是用来做什么的了

Step3【自定义CredentialsMatcher、Token】

1、自定义Token


@Data
public class Token implements AuthenticationToken {
    private final String token;

    public Token(String token) {
        this.token = token;
    }

    @Override
    public Object getPrincipal() {
        return token;
    }

    @Override
    public Object getCredentials() {
        return token;
    }
}
  • 继承 AuthenticationToken即可
  • 很简单,贴合我们的验证方式。登录时返回一个Token令牌,访问其他接口时,携带上登录时的Token令牌
  • 我们就可以使用这个 Token去认证、授权 ...
  • 如果你看过了上一篇Shiro的文章,那么你看到getPrincipal()、getCredentials()方法,应该知道是什么
  • 而它们的返回值,都是传入的 Token,这是为什么呢?这里我们称为疑惑二
  • 耐心的你要是读到了这里,估计都骂我"balabala..."了,没关系,我们再看自定义CredentialsMatcher

2、自定义CredentialsMatcher


public class TokenMatcher implements CredentialsMatcher {
    @Override
    public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
        return true;
    }
}
  • 如果你看完了上一篇文章的认证流程,那么你应该知道为什么要继承CredentialsMatcher,重写doCredentialsMatch()方法了吧
  • 而我们这里的实现更简单,直接返回 true,代表密码Credentials匹配成功
  • 那你肯定会有疑问了:疑问三,这里不是要验证密码Credentials吗?为什么直接放行,让其密码认证通过?

3、Q:

  • 写到这里,我们先来回看一下上疑问
  • 疑问一:在doGetAuthenticationInfo()方法里,没有验证用户名,直接将传入的Token构建成account返回了
  • 疑问二:在自定义Token的时候,重写getPrincipal()、getCredentials()实现,为什么都返回传入的 token
  • 疑惑三:在自定义CredentialsMatcher时,为什么没有直接放行

4、A:

  • 如果有这些疑问,我们画一张图回顾一下,我们想要实现的目标的流程

image-20221010154930599

  • 从这张图,我们可以看到,服务器返回 Token令牌 给客户端,是在登录之后
  • 并且是成功登录之后,如果有用户名 or 密码错误,服务器根本不会返回Token令牌给客户端
  • 那么,你也就知道,在Shiro这里,为什么不需要验证用户名、密码了吧
  • 而疑问二,重写实现,主要是因为我们自定义的 Token不需要使用用户名和密码。只需要维护一个自定义的 token 字符串,所以直接将其字符串返回【后面也不一定会用到,只是必须实现该抽象方法,在需要使用的时候,更方便的做类型转换罢了】
  • PS:回看一下为什么要自定义Shiro,你会发现,其实我们已经知道这几个疑问了,嘿嘿👷👷👷~

Step4【自定义Filter】


public class TokenFilter extends AccessControlFilter {
    /**
     * 当请求被TokenFilter拦截时,就会调用这个方法
     * 可以在这个方法中做初步判断
     *
     * 如果返回true:允许访问。可以进入下一个链条调用(比如Filter、拦截器、控制器等)
     * 如果返回false:不允许访问。会进入下面的onAccessDenied方法,不会进入下一个链条调用
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
        return false;
    }

    /**
     * 当isAccessAllowed返回false时,就会调用这个方法
     * 在这个方法中进行token的校验
     *
     * 如果返回true:允许访问。可以进入下一个链条调用(比如Filter、拦截器、控制器等)
     * 如果返回false:不允许访问。
     */
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
       
        // 取出Token
        String token = request.getHeader("Token");

        // 如果没有Token
        if (token == null) {
            throw new InvalidParameterException("没有Token,请登录");
        }

        // 如果Token过期了
        if ( /* 通过 token 取不出用户信息 */ ) {
            throw new InvalidParameterException("Token已过期,请重新登录");
        }

        // 去认证且授权(进入Realm)
        // 这里调用login,并不是“登录”的意思,是为了触发Realm的相应方法去加载用户的角色、权限信息,以便鉴权
        SecurityUtils.getSubject().login(new Token(token));
        return true;
    }
}
  • 继承Shiro提供的AccessControlFilter(最终也是继承 servelet的 filter
  • 实现isAccessAllowed()、onAccessDenied()两个方法
  • !!!调用SecurityUtils.getSubject().login(new Token(token));
  • 这里是去走通用Shiro认证授权流程
  • 相应的描述,我写在了注释中,画一幅图,来说明一下这个流程

image-20221010204902949

  • 举一个例子理解一下:
    • 1、客户端对服务器发起恋爱请求😵🐶🐶
    • 2、isAccessAllowed()方法对客户端进行初步判断
    • 3、如果服务器对客户端也很有感觉,那么直接同意了他的恋爱请求,将其放行到下一链条
    • 4、如果服务器对客户端感觉不是那么好,但是又不想直接拒绝,还想再观察观察。将其放入到onAccessDenied()方法中
    • 5、经过一系列严格检验,发现客户端其实还不错,同意他的恋爱请求,将其放行到下一链条
    • 6、严格检验后发现客户端不太合适,那就直接pass了,没有反转的余地

四、具体实现总结

(1)流程

  • 跟着我一起走完了定制化ShiroStep1、2 ...
  • 相信你已经有了不少的收获,那我们在来总结一下这几个步骤吧~
  • 这一套流程,我们已经过了一遍,如果还没有走通,那么就还差几张图~🖼️🖼️
  • 注:我们这里并没有谈到Shiro的缓存管理器:CacheManager

image-20221011083306709

  • 上图是一个不需要权限or角色即可访问的接口,一个认证的流程
  • 下图是一个需要权限or角色才能访问的接口,一个认证和鉴权的流程

image-20221011085033180

  • 下图是访问一个需要权限or角色的接口的执行流程

image-20221010215443083

(2)额外补充【怎么利用token查询用户的角色、权限信息】

  • 如果你前面的流程没有什么问题,那你可能有一个疑惑,这个从登录开始就一直维护的token,是如何代表用户信息的呢?
  • 我这里说两种常用的方案

1、Token + Cache

  • 这种方案,顾名思义,在登录的时候,生成一串字符串(Token)
  • 利用这个 Token作为 key 将其信息缓存起来
  • 在之后的请求中,使用这个 Token作为key,从缓存中取出当初存储的信息
  • 而将缓存放在哪里呢?
    • 服务器内存、JVM内存、Redis数据库
    • 甚至你还可以存储在Mysql这种关系型数据库中(不推荐)
  • 其实放在哪里都可以,具体的得看业务需求,业务体量

2、Json Web Token JWT

  • 这种方案也很常用,在登录的时候,将用户信息,利用一定加密、签名算法
  • 生成一串,有一定格式的字符串(Json Web Token)
  • 在之后的请求中,使用当初生成这个JWT字符串的规则,逆向解析出用户的信息

(3)再谈shiroFilterFactoryBean()方法

  • 当我们定制化完成后,我们还需要将其添加到Shiro的配置里,并且放入IoC中。
  • 先奉上刚刚欠下的常用配置

image-20221011103741183

  • 下面是上图中标序号的注意事项

    • ① 方法名字必须为shiroFilterFactoryBean

    • ② 安全管理器的类型为 DefaultWebSecurityManager

    • ③ 使用的Realm必须放入 Spring IoC容器

    • ④ 若有自定义的Filter 必须配置,key 为下面URI 使用的名称,可以配置多个

    • ⑤ 添加URI映射的时候,必须保证遍历的时候是有序的。所以使用LinkedHashMap

    • ⑥ 配置的URI越靠前,优先级越高,并且可以同时使用多个。使用自己的 Filter,名字为当初设置时的key

  • 除了使用自定义的过滤器,Shiro还提供了很多默认的DefaultFilter

image-20221011105351555

  • 具体使用请查看文档,比较常用的有 anno匿名filter【相当于直接放行】
  • 下面是一个配置的模板

	/**
     * Shiro过滤器工厂
     * @param realm:Shiro数据源
     * @param properties:项目配置
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(Realm realm, WorkBoardProperties properties) {
        ShiroFilterFactoryBean filterBean = new ShiroFilterFactoryBean();
        // 安全管理器【并且告诉使用上面realm】
        filterBean.setSecurityManager(new DefaultWebSecurityManager(realm));
        
        // 添加自定义 Filter
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("token", new TokenFilter());
        filterBean.setFilters(filterMap);

        // 添加 URI 映射
        Map<String, String> uriMap = new LinkedHashMap<>();

        // 放行登录&注册接口&发送验证码&忘记密码
        uriMap.put("/admin/users/login", "anon");
        uriMap.put("/admin/users/register", "anon");
        uriMap.put("/admin/users/sendEmail", "anon");
        uriMap.put("/admin/users/sendTest", "anon");
        uriMap.put("/wx/users/getSessionId", "anon");
        uriMap.put("/admin/users/forgotPwd", "anon");
        uriMap.put("/admin/users/captcha", "anon");

        // 放行Swagger文档
        uriMap.put("/swagger**/**", "anon");
        uriMap.put("/v3/api-docs/**", "anon");

        // 放行获取静态资源的URI
        uriMap.put("/" + properties.getUpload().getUploadPath() + "**", "anon");

        // 其他 URI 使用自定义的 filter
        uriMap.put("/**", "token");

        filterBean.setFilterChainDefinitionMap(uriMap);
        return filterBean;
    }

写在后面

(1)读后思考

  • 定制化Shiro的过程
  • 定制化Shiro后的执行流程

(2)下篇预告

  • Shiro集成到Spring Boot 的简单案例,并且会提供源码
  • 是真的简单案例,我会模拟数据库、缓存
  • 不会涉及过多的其他知识点
  • 咱们将重点,放在Shiro框架上面~