我正在参加「掘金·启航计划」
简单安全管理框架——Shiro进阶
写在前面
(1)本文摘要
- 为何要定制化Shiro
- 自定义 Realm
- 自定义 CredentialsMatcher
- 自定义 Token
- 自定义 Filter
- 将Shiro集成到 Web项目的具体步骤
(2)读前须知
- 本文是承接着上一篇Shiro的文章
- 推荐可以先看看 Shiro篇①——基础篇
- 上一篇Shiro的文章,操作都是对
JavaSE的项目而言的。可我们怎么将Shiro集成到Web项目 - 并且定制化配置,自定义的内容如下
- 过滤器(Filter)、数据源(Realm)
- 校验规则(Token)、密码匹配规则(CredentialsMatcher)
一、目标实现
- 现在前后端分离的项目中,有着各种各样的会话管理方案。而今较为常用的有Token + xxx
- 我们想要用
Shiro做认证授权,刚好可以和Token结合起来,
- 登录接口:返回Token令牌
- 其他未放行接口:携带登录时返回的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很好的结合起来 - 所以我们总结一下为什么要自定义过滤器:
- 为了过滤请求、可以检验用户是否携带
身份令牌Token,Token是否过期,去加载用户的角色,去加载用户的权限... - 使用过滤器,可以做到书写一次验证代码,所有接口适用
三、具体实现
- 说完了为什么要定制化
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来做什么
- 如上图所示,我们就想要做到这种效果。
- 请求从客户端发送过来,通过
Shiro中断一下 - 1、决定我们的请求是否能调用controller【认证】
- 2、到达了controller,是否需要去鉴权【授权】
- 所以,我们要去配置一个
Shiro过滤的工厂对象,并且将其放入Spring的IoC容器中
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(Realm realm) { }
- 在这个方法里,我们需要:
- 告诉
Shiro如何进行拦截 - 拦截那些
URL - 每个
URL需要进行那些filter
- 告诉
- 如果你看完了之前的内容,你肯定知道
Shiro是通过安全管理器来管理自己的一些列流程的 - 那我们就需要告诉
ShiroFilterFactoryBean,我们的安全管理器 - 而设置安全管理器的时候,我们又需要告诉安全管理器,我们需要使用什么数据源
- 而设置数据源的时候,我们又需要告诉数据源。使用什么校验规则、密码匹配规则
- 如上图所示,聪明的你,应该能猜到,我们 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校验令牌的
- 从上图,我们可以知道,
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:
- 如果有这些疑问,我们画一张图回顾一下,我们想要实现的目标的流程
- 从这张图,我们可以看到,服务器返回
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认证授权流程 - 相应的描述,我写在了注释中,画一幅图,来说明一下这个流程
- 举一个例子理解一下:
- 1、客户端对服务器发起恋爱请求😵🐶🐶
- 2、
isAccessAllowed()方法对客户端进行初步判断 - 3、如果服务器对客户端也很有感觉,那么直接同意了他的恋爱请求,将其放行到下一链条
- 4、如果服务器对客户端感觉不是那么好,但是又不想直接拒绝,还想再观察观察。将其放入到
onAccessDenied()方法中 - 5、经过一系列严格检验,发现客户端其实还不错,同意他的恋爱请求,将其放行到下一链条
- 6、严格检验后发现客户端不太合适,那就直接pass了,没有反转的余地
四、具体实现总结
(1)流程
- 跟着我一起走完了定制化
Shiro的Step1、2 ... - 相信你已经有了不少的收获,那我们在来总结一下这几个步骤吧~
- 这一套流程,我们已经过了一遍,如果还没有走通,那么就还差几张图~🖼️🖼️
- 注:我们这里并没有谈到Shiro的
缓存管理器:CacheManager
- 上图是一个不需要权限or角色即可访问的接口,一个认证的流程
- 下图是一个需要权限or角色才能访问的接口,一个认证和鉴权的流程
- 下图是访问一个需要权限or角色的接口的执行流程
(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中。 - 先奉上刚刚欠下的常用配置
-
下面是上图中标序号的注意事项
-
① 方法名字必须为
shiroFilterFactoryBean -
② 安全管理器的类型为
DefaultWebSecurityManager -
③ 使用的
Realm必须放入Spring IoC容器中 -
④ 若有自定义的
Filter必须配置,key 为下面URI 使用的名称,可以配置多个 -
⑤ 添加URI映射的时候,必须保证遍历的时候是有序的。所以使用
LinkedHashMap -
⑥ 配置的URI越靠前,优先级越高,并且可以同时使用多个。使用自己的
Filter,名字为当初设置时的key
-
-
除了使用自定义的过滤器,
Shiro还提供了很多默认的DefaultFilter
- 具体使用请查看文档,比较常用的有
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框架上面~