本文假设读者对Shiro有部分了解,并且有使用经历。对于一个框架首先要会用它,再来看原理就透彻多了。如果没用过可以看看这篇SpringBoot使用Shiro。
下载地址,本文讲解的是1.7.1,我下载的也是这个版本。版本最好和我相同,要不然Shiro新增或修改的一些功能不会再这篇文章中体现出来。
可以看到Shiro的模块非常的多。但其实主要模块就3个,被我选中标记了
其他模块
Shiro使用外观模式,SecurityManager包含系统的所有功能,他的实现树如下:
我将依次讲解:
Authenticator
:认证器Authorizer
:授权器SessionManager
:会话管理器SecurityManager
:安全管理器认证器位于code包下的authc下。对于安全系统,最主要的功能是如何获取访问者的信息,并与我们所设立的白名单中的用户相匹配。Authenticator对于Shiro来说就是定义这样一个功能,他的方法如下:
只有一个认证方法,接收一个AuthenticationToken,返回一个认证信息。他的实现树如下:
忽略AbstractAuthenticator,因为他将authenticate()
,委托给了内部的抽象方法doAuthenticate()
,最终由ModularRealmAuthenticator实现,具体如下:
如上图注释所说,ModularRealmAuthenticator又将认证器具体获取的信息交给了Realm实现。
doSingleRealmAuthentication(Realm,AuthenticationToken)
具体实现如下:
!realm.supports(token)
AuthenticationInfo info = realm.getAuthenticationInfo(token);
doMultiRealmAuthentication()实现与之类似,只不过增加了多Realm策略:
默认使用 AtLeastOneSuccessfulStrategy
接下来我们来了解认证器使用的Token到底是什么,有什么功能,下面是shiro内置的Token:
AuthenticationToken
getCredentials()
:获取认证凭证,一般来说是密码getPrincipal()
:获取认证主体,一般来说是用户名RememberMeAuthenticationToken extends AuthenticationToken
AuthenticationToken
isRememberMe()
:是否记住我HostAuthenticationToken extends AuthenticationToken
AuthenticationToken
getHost()
:获取主机名或 ipUserNamePasswordToken implements RememberMeAuthenticationToken, HostAuthenticationToken
BearerToken implements HostAuthenticationToken
HostAuthenticationToken
每一个Realm都有他支持的Token,通过support(AuthenticationToken)
就可以知道他是否支持此token。
AuthenticationInfo接口 是 authenticate()
的返回值,他的结构非常简单:
PrincipalCollection getPrincipals();
Object getCredentials();
他的几个实现类也非常简单,不做过多赘述,有兴趣的可以自己看看。一般使用 SimpleAuthenticationInfo 作为返回值
之前我们说 获取用户的信息会委托到Realm,AuthenticatorRealm
是委托的具体类,他是一个抽象类,实现他来完成自己的自定义认证Realm,下面是他的实现树和方法
Realm.getAuthenticationInfo()
只有一个实现:AuthenticatingRealm
进入到方法体内
我们逐句分析
AuthenticationInfo info = getCachedAuthenticationInfo(token);
首先,获取缓存中的认证信息。什么意思呢?
就是说你用 admin+123456 创建了一个token登录,他在缓存里用 admin 找一下,看看你有这个认证信息没有,有就直接返回了。没有就进入下一步
info = doGetAuthenticationInfo(token);
这个获取就是要委托子类,也就是抽象方法。是要委托给我们自己写的realm,当然shiro他自己也有实现很多realm,但是我们一般不用
cacheAuthenticationInfoIfPossible(token, info);
缓存认证信息,这个方法他有个判断,判断你是否开启缓存,默认是关闭的。也就是说你没具体设置过第一步是不能获取到任何信息的,还是需要进入到第 2 步。开启缓存有利有弊,利就是重复登录不再需要进入第 2 步,弊是编码要复杂了,修改密码了,你还要把这个缓存同步
assertCredentialsMatch(token, info);
认证信息中的密码和token中的是否匹配,不匹配抛出一个 IncorrectCredentialsException
异常。默认的密码匹配器是很简单的字符匹配,就是匹配两个字符是否相同,所以说如果你数据库中密码是 HASH 过的,你在构建 info 时密码就使用用户的输入,判断在Realm中做好。或者你可以使用 HashedCredentialsMatcher
来进行密码匹配
授权器的实现和认证器非常相似,也是将授权信息的获取委托给Realm。下面是Authorizer接口的方法列表:
别看方法非常多,其实就是两个功能:查看这个主体也就是登录的用户有没有这个角色,查看这个主体有没有这个权限
接口的实现树:
是不是和Authemticator非常相似,但他的实现下面多了一个AuthorizingRealm,这个我们之后再讲。我们看一下 isPermitted(PrincipalCollection,String)
assertRealmsConfigured();
!(realm instanceof Authorizer)
((Authorizer) realm).isPermitted(principals, permission)
可以看到,和认证器不同,授权器对多个授权Realm使用的策略是只要有一个可以通过授权,就算做成功。
Authorizer使用的Realm 为 AuthorizerRealm ,他继承于AuthenticatorRealm 。也就是说,实现授权Realm 也需要实现认证Realm。看一看他的关键方法 AuthorizationInfo getAuthorizationInfo(PrincipalCollection)
Cache
Object key = getAuthorizationCacheKey(principals);
info = doGetAuthorizationInfo(principals);
cache.put(key, info);
然后让我们回到AuthorizingRealm中,他实现了Authorzing ,我们来看一下isPermitted(PrincipalCollection,Permission)
这个方法是怎么做的
AuthorizationInfo info = getAuthorizationInfo(principals);
return isPermitted(permission, info);
Collection perms = getPermissions(info);
perm.implies(permission)
现在我们大概知道了认证的信息和权限是怎么获取的,也清楚我们的Realm是如何被调用生效的。接下来就是Shiro是如何将我们登录的状态保存。
shiro有两种类型的Session,一种是NativeSession本地会话,也就是Shiro自己实现的;一种是ServletContainerSession容器会话,也就是直接用容器的Session,比方说你是用Tomcat,shiro用的就是tomcat的Session。
讲解之前先明确几个功能接口
如何获取的SessionId?
使用客户端传输的Cookie获取,shiro在创建Session后会将 set-cookie响应头添加到响应中去,下次请求客户端就会携带带有SessionId的cookie
SessionDao如何保存Session?
使用shiro Cache保存,shiro默认实现有 MapCache,EhCache。你可以自定义实现自己的缓存,如使用Redis,Mysql
SessionListener有什么用?
实现自己的监听器,在Shiro Session的创建、停止、到期时实现自己的逻辑。监听器只有在使用本地会话管理器的时候才会生效。
Session如何过期?
每次获取或生成Session时都会判断有没有启动 SessionValidationScheduler,没有启动则启动。他所做的就是周期性的监测Session有无过期,过期则删除。同样,此功能只有在使用本地Session才会开启,因为使用容器Session时,容器已经帮我们做到这一点了。
Shiro对容器会话并没有太多操作,只是将其封装了一下。像会话的生成,到期这些功能全都由容器做好了。
现在我们将上面串联起来,结合安全管理器。
shiro将每一个客户端的请求都抽象成了一个Subject,利用SecurityUtils.getSubject()可以获取当前的Subject,下面看看他的代码
Subject subject = ThreadContext.getSubject();
subject = (new Subject.Builder()).buildSubject();
ThreadContext.bind(subject);
进入到buildSubject():
subjectContext实现Map,是一个包含认证信息、是否认证、sessionId的信息类。最终创建Subject还是委托到了SecurityManager.createSubject(SubjectContext),进入:
SubjectContext context = copy(subjectContext)
上面说过SubjectContext实现Map,这里就是复制一份
context = ensureSecurityManager(context);
判断一下SecurityManager是不是空,是空就把自己赋值给context。
context = resolveSession(context);
这里我们重点看 红框标记的 Session session = resolveContextSession(context);
先从context中获取一个SessionKey,也就是SessionId,注意这里使用的是SecurityManager的方法,他可以被子类继承重写。getSession(key),直接委托到了SessionManager,不细讲。
context = resolvePrincipals(context);
context.resolvePrincipals():这里是从上一步resolveSession中获取的Session里去取Principal,如果Session为空,这里就直接到下一步了。
getRememberedIdentity(context):获取记住我的值,从web上来说,实现的是读取客户端传过来的cookie,然后进行解析
Subject subject = doCreateSubject(context);
获取一个工厂创建Subject,具体实现就是把context的值复制给Subject。非常简单,不过多讲述了
save(subject);
进入subjectDAO.save(subject);
isSessionStorageEnabled(subject):判断一下subject是否要Session存储
saveToSession(subject):保存到Session
进入到saveToSession(subject);
合并认证主体到Session,合并认证状态到Session。
安全管理器本身逻辑非常少,只做一个整合功能。这也符合了外观模式的观点,将一个复杂系统的各类接口整合,提供一个统一的界面。上述功能实现其实忽略了一点东西,为了更好的结合使用体验,可以模糊了core模块和web模块之间的界限。想要得到更详细更正确的理解,还是得自己阅读源码,本文只提供一个大概脉络。
我们在代码内部使用 SecurityUtils.getSubject();总能获取到完整的Subject。但根据上面的代码来看,SecurityUtils.getSubject();只能提供一个空的Subject,里面甚至都没有Session,这代表我们登录状态根本不会保存。这一步其实Shiro已经给我们做好了,它使用Web Filter在所有请求处理之前将Subject提供给当前线程,这样我们在代码中使用就无需自己创建。
下文的容器指实现 javax.servlet 包的程序,如Tomcat
我个人认为ShiroFilter分为两种:
Shiro就是在第一种过滤器中生成Subject,并绑定到当前线程,使得SecurityUtils.getSubject();能返回带有Session的Subject。
转到AbstractShiroFilter.doFilterInternal()
方法
createSubject(request, response)
结合之前的,这里创建一个Subject并给予一个请求和响应,在这里shiro为我们创建好了一个Subject,并赋值了必须的参数,
接着,我们进入 subject.execute(Callable)
看看它的源码
Callable可使用多线程调用并返回Futrue值,但这里直接调用没有使用多线程调用,说明shiro只是把Callable当做一个任务类。
接着我们转到associateWith()
,看看是怎么构建这个Callable的。
把subject自身和传入的Callable通过构造函数构建一个SubjectCallable
通过传入Subject构建一个ThreadState类、并把Callable存储到当前类
我们转入ThreadState
原来他就是将当前subject绑定到ThreadContext中,也就是当前线程的ThreadLocal。
在callable调用之前将Subject绑定,也就是call()方法内调用ThreadContext.getSubject()都能获取到传入的Subject。之后在执行完毕,调用ThreadState.restore(),清空ThreadContext并复原。
updateSessionLastAccessTime(request, response);
executeChain(request, response, chain);