前言
只要是权限校验框架,就离不开两个步骤:认证(Authentication)和授权(Authorization)。认证即“用户登录”,授权即“允许用户访问目标URI”。这篇博客介绍了Shiro 1.3.2版本认证与授权这两个过程的执行链条,未涉及到更高级的功能(如单点登录、RememberMe)。
认证
Shiro 认证的入口很简单,就是下面三行代码:
1  | UsernamePasswordToken token = new UsernamePasswordToken(username, password);  | 
这三行代码写在哪里由我们自己定。
第一行,是将用户输入的 username、password 封装成UsernamePasswordToken对象,这个类的顶级父类接口是AuthenticationToken,后面进入Realm中被认证的就是它。
第二行调用SecurityUtils.getSubject()取一个Subject对象,这个方法源码如下:
1  | public static Subject getSubject() {  | 
在认证代码这里,从 ThreadContext 是可以取到 subject 对象的,在这里 subject 对象使用的实现类是WebDelegatingSubject。ThreadContext 是用ThreadLocal<Map<Object, Object>> resources保存 subject 对象。
问题:ThreadContext 中的 subject 对象怎么来的?简单地说,是用户请求经过 SpringShiroFilter 时,SpringShiroFilter 根据请求创建 subject 并设置到 ThreadContext 中的,详细见博客《SpringShiroFilter原理》的“工作原理-创建 Subject 对象”一节。
第三行subject.login(token)这个方法便是完整的用户认证的过程。步骤简述如下:
- 包含 username 和 password 的
UsernamePasswordToken对象进入Subject.login()方法 - 在 Subject.login() 中,调用
SecurityManager.login()方法,使用的 SecurityManager 实现类是在“Shiro配置文件”指定的,一般是DefaultWebSecurityManager - 在 SecurityManager.login() 中,调用
Authenticator.authenticate()方法。默认调用到 Authenticator 的实现类ModularRealmAuthenticator - 在 ModularRealmAuthenticator.authenticate() 中,当其成员
Collection<Realm> realms只有一个元素时(有多个元素的情况此处不提),就调用这一个Realm的getAuthenticationInfo()方法。”realms”的元素数量取决于在“Shiro配置文件”中给 SecurityManager 设置了几个 Realm 实现类 - Realm 接口的 getAuthenticationInfo 方法由子类
AuthenticatingRealm实现,这个类定义了钩子方法doGetAuthenticationInfo,交给我们自定义 Realm 实现。这个方法就是根据入参AuthenticationToken对象(UsernamePasswordToken 的接口类型),查询用户原信息,校验用户密码等,即进行所谓的认证。认证成功会返回AuthenticationInfo对象,一般使用实现类SimpleAuthenticationInfo - 若认证失败,可抛出
AuthenticationException及其子类的异常,包括:DisabledAccountException(禁用的帐号)、UnknownAccountException(错误的帐号)、IncorrectCredentialsException (错误的凭证)等。这些异常可在调用subject.login(token)的地方捕获并处理 - 认证成功后,Realm 接口的 getAuthenticationInfo 方法返回的 AuthenticationInfo 对象一直向上传递到 SecurityManager.login() 方法
 - 在 SecurityManager.login() 中,获得 AuthenticationInfo 后,会创建一个新 Subject 对象。这个新 Subject 对象中保存了代表用户身份的
PrincipalCollection principals,这个 principals 的来源就是 AuthenticationInfo - SecurityManager.login() 将创建的“新subject”向上传递到 subject.login() 方法,“新subject”中包含的
principals和authenticated(已认证标志,认证成功后设置为TRUE) 会更新到“旧subject”(即 login 方法的调用方)中 
至此,认证完成。
如果配置启用“Session”(默认启用),在 SecurityManager.login() 获得 AuthenticationInfo 后创建新 subject 时,会同时创建一个 Session 接口类型的对象(这个 Session 接口是 Shiro 定义的,不是 Tomcat 定义的)。这个 session 对象中也会保存 principals,并且这个 session 对象会被保存到内存中,sessionId 写入响应头,下次用户请求会带着 sessionId 过来,取出内存中保存的 session 对象。
这部分内容详见博客《SpringShiroFilter原理》的“工作原理-创建 Subject 对象”一节。
认证成功流程:
认证失败流程:
AuthenticationInfo和getAuthenticationInfo方法
Shiro 的Realm接口有两个重要的实现类:AuthorizingRealm(授权)和AuthenticatingRealm(认证),并且“认证”是“授权”的父类。AuthenticatingRealm.getAuthenticationInfo()是执行用户认证逻辑的方法,返回AuthenticationInfo对象,一般使用实现类SimpleAuthenticationInfo。
AuthenticationInfo接口只定义了2个方法:
1  | public interface AuthenticationInfo extends Serializable {  | 
其中,”getPrincipals”返回的代表了用户身份,是个集合对象,说明支持一个用户多个“身份”。这个“身份”可以是字符串,也可以是一个 Object 对象。”getCredentials”返回的代表了用户身份的密码,一般保存的是用户账号的密码字符串。
在AuthenticatingRealm.getAuthenticationInfo()方法中,先尝试从“缓存”中取 AuthenticationInfo,若取出的 info 为 null,则调用本类的doGetAuthenticationInfo方法,这个方法由子类实现。从 doGetAuthenticationInfo 方法返回 info 后,会把它保存到“缓存”中。最后,会判断这个 info 与 AuthenticationToken 中的 credentials 是否一致,不一致则认证失败,一致则返回 info 对象。
AuthenticatingRealm 中的“缓存”,是用下面这两个方法取的:
1  | private Cache<Object, AuthenticationInfo> getAvailableAuthenticationCache() {  | 
因为在默认配置下,AuthenticatingRealm 不会使用缓存,所以用户认证时一定会进入 doGetAuthenticationInfo 方法。【默认不使用缓存的原因应该是,用户一般不会多次重复登录。
与Spring Security的区别
- Shiro 的认证入口在
subject.login(token)方法。Spring Security 的认证入口是UsernamePasswordAuthenticationFilter.doFilter()方法 - Shrio 的调用链条简单,需要实现的就是
AuthenticatingRealm.doGetAuthenticationInfo()方法,在这里实现查询原用户信息、校验用户名密码这2个必要步骤的逻辑。Spring Security 把这两个步骤分开在了UserDetailsService和AuthenticationProvider上,要分别实现(不用Spring默认配置的话) - Shiro 没有做默认实现类和自动装配。Spring Security 提供了几乎所有接口的默认实现类和自动装配,所以只要引入jar包就能用
 
授权
Shiro 实现授权校验的方式有多种:
- 在“Shiro配置文件”为需要授权的 URI 配置授权类的过滤器,即
AuthorizationFilter的子类,如PermissionsAuthorizationFilter - 在需要授权的接口 Controller 方法上使用
@RequiresPermissions、@RequiresRoles注解 
无论使用哪种方式,授权校验时调用的都是Subject类中的这些方法:
权限校验方法:
1  | boolean isPermitted(String permission);  | 
角色校验方法:
1  | boolean hasRole(String roleIdentifier);  | 
这些方法由DelegatingSubject实现,每个方法的实现方式就是调用成员SecurityManager的同名同返回类型方法,以下面这个方法为例:
1  | public boolean isPermitted(String permission) {  | 
方法参数上,SecurityManager 的同名方法要多传一个PrincipalCollection principals,DelegatingSubject 会取出自己的 principals 传给 SecurityManager。如果配置启用“Session”,已登录用户的请求经过 SpringShiroFilter 时,SpringShiroFilter 会根据用户请求取出 session,再从 session 中取出 principals,设置到这里的 subject 对象。
如果禁用“Session”,那么在调用这些方法前,需要自己往 subject 对象中设置 principals 再调用这些方法。
往 subject 中设置 principals,并替换原有的由 SpringShiroFilter 创建的 subject 对象的代码如下:
1  | // 旧subject  | 
这段代码要写在调用那些权限校验方法、角色校验方法前。
回到 Subject 的授权校验方法。上面说到,Subject 的这些方法都是调用 SecurityManager 的同名同返回类型方法。而 SecurityManager 的这些方法又是调用它的成员ModularRealmAuthorizer的相同方法(同名同参同返回)。然后,ModularRealmAuthorizer 中又是调用AuthorizingRealm的相同方法(同名同参同返回)。所以,要研究 Shiro 的授权逻辑,只要研究 AuthorizingRealm 就行。【上面提到过,它的父类是“认证”Realm,AuthenticatingRealm
上面提到的 SecurityManager、ModularRealmAuthorizer、AuthorizingRealm 这3个类,它们的校验方法全部相同,都是因为它们继承了同一个接口Authorizer。即,SecurityManager、ModularRealmAuthorizer 依赖着与自己相同接口的成员,只有 AuthorizingRealm 真正实现了校验逻辑。
Authorizer 接口定义的校验方法和前面我们列出的 Subject 定义的几乎一模一样,唯一不同的就是 Authorizer 的这些方法的参数多了一个PrincipalCollection principals。下面根据 AuthorizingRealm 的实现,列出这些方法的作用。
授权校验方法
一、权限校验
1  | boolean isPermitted(PrincipalCollection principals, String permission);  | 
判断用户是否具有某个权限。String 类型的会先转成Permission类型的再判断,这点同下面的方法。
1  | boolean[] isPermitted(PrincipalCollection principals, String... permissions);  | 
判断用户是否具有参数列出的权限,返回每个权限的校验结果数组。
PS: 实际上是对每个权限遍历调用isPermitted(PrincipalCollection principals, Permission permission)
1  | boolean isPermittedAll(PrincipalCollection principals, String... permissions);  | 
判断用户是否具有参数列出的权限,与上一个不同点是,只有每个权限校验都返回 true,这个方法才返回 true。
PS: 实际上是对每个权限遍历调用isPermitted(PrincipalCollection principals, Permission permission)
1  | void checkPermission(PrincipalCollection principals, String permission) throws AuthorizationException;  | 
作用同isPermitted方法,不同点是,若用户无权限,会抛出UnauthorizedException。
PS: 实际上也是调用isPermitted(PrincipalCollection principals, Permission permission)
1  | void checkPermissions(PrincipalCollection principals, String... permissions) throws AuthorizationException;  | 
对参数列出的所有权限,遍历调用上一个checkPermission方法,只要校验出一个无权限,就抛出UnauthorizedException。
二、角色校验
1  | boolean hasRole(PrincipalCollection principals, String roleIdentifier);  | 
判断用户是否具有某个角色。
1  | boolean[] hasRoles(PrincipalCollection principals, List<String> roleIdentifiers);  | 
判断用户是否具有参数列出的角色,返回每个角色的校验结果数组。
PS: 实际上是对每个角色遍历调用上一个hasRole方法
1  | boolean hasAllRoles(PrincipalCollection principals, Collection<String> roleIdentifiers);  | 
判断用户是否具有参数列出的角色,与上一个不同点是,只有每个角色校验都返回 true,这个方法才返回 true。
PS: 实际上是对每个角色遍历调用hasRole方法
1  | void checkRole(PrincipalCollection principals, String roleIdentifier) throws AuthorizationException;  | 
作用同hasRole方法,不同点是,若用户无角色,会抛出UnauthorizedException。
PS: 实际上也是调用hasRole方法
1  | void checkRoles(PrincipalCollection principals, Collection<String> roleIdentifiers) throws AuthorizationException;  | 
对参数列出的所有角色,遍历调用上一个checkRole方法,只要校验出一个无角色,就抛出UnauthorizedException。
总结:
通过这些授权校验方法的源码,我们可以看出,权限校验方法的基础是isPermitted(PrincipalCollection principals, Permission permission),角色校验方法的基础是hasRole。AuthorizingRealm 对这两个方法的实现思路相同,如下源码:
1  | // 权限  | 
即校验权限/角色前,都要先调用getAuthorizationInfo方法获取AuthorizationInfo对象,再用这个AuthorizationInfo对象进行校验。
AuthorizationInfo和getAuthorizationInfo方法
上面讲到认证逻辑时,AuthenticatingRealm.getAuthenticationInfo()返回的对象是AuthenticationInfo。这里,AuthorizingRealm.getAuthorizationInfo()返回的对象是AuthorizationInfo,一般使用实现类SimpleAuthorizationInfo。
AuthorizationInfo接口只定义了3个方法:
1  | public interface AuthorizationInfo extends Serializable {  | 
很明显,这个对象就是用来保存用户的“角色(字符串)”、“权限(字符串)”、“权限(Permission类型)”。
在AuthorizingRealm.getAuthorizationInfo()方法中,先尝试从“缓存”中取 AuthorizationInfo,若取出的 info 为 null,则调用本类的doGetAuthorizationInfo方法,这个方法由子类实现。从 doGetAuthorizationInfo 方法返回 info 后,会把它保存到“缓存”中。最后返回 info 对象。
AuthorizingRealm 取“缓存”的方法与 AuthenticatingRealm 完全一致(源码如下),不同的是,AuthorizingRealm 是默认会使用缓存的。
1  | private Cache<Object, AuthorizationInfo> getAvailableAuthorizationCache() {  | 
因为 AuthorizingRealm 默认会使用缓存,所以如果有给 AuthorizingRealm 配置缓存,每次权限校验就不都会进入到 doGetAuthorizationInfo 方法了。
权限字符串的转换
在我们自定义实现的doGetAuthorizationInfo方法中,一般是从数据库中取出用户的权限字符串保存到AuthorizationInfo中。但是我们知道,进行权限校验时,权限字符串都会被转成Permission类型的权限对象再进行判断。负责这个转换的是 AuthorizingRealm 的成员PermissionResolver,默认使用实现类WildcardPermissionResolver。
因为这个原因,Shiro 的权限字符串要遵循WildcardPermissionResolver要求的格式:资源标识符:操作:对象实例ID。如”system:user:update”中,”system:user”是资源标识符,”update”是操作,对象实例ID为空。对象实例ID一般用不到,因为太具体了。
权限字符串支持符号:”:”表示资源/操作/实例的分割;”,”表示操作的分割;”*”表示任意资源/操作/实例。
举例:
- 用 system:user:update,delete 验证 system:user:update,system:user:delete 可以,反之不行
 - 用 system:user:* 验证 system:user:update,delete,add 可以,反之不行
 - *:delete 等同于 system:user:delete,也等同于 user:delete(区别是资源标识符是一级还是两级)
 
如果自定义了 PermissionResolver 实现,那么可以自定义将权限字符串转换为 Permission 对象的方法,字符串格式也就可以自己定。
使用权限注解时的配置
前面说到,Shiro 实现授权校验的方式有:
- 在“Shiro配置文件”为需要授权的 URI 配置授权类的过滤器,即
AuthorizationFilter的子类,如PermissionsAuthorizationFilter - 在需要授权的接口 Controller 方法上使用
@RequiresPermissions、@RequiresRoles注解 
如果使用第二种的注解方式,在“Shiro配置文件中”除了常规的配置,还需要加上以下内容:
1  | 
  | 
第一个 Bean 是AuthorizationAttributeSourceAdvisor是 Shiro 提供的,它间接实现了接口Advisor。
第二个 Bean 是DefaultAdvisorAutoProxyCreator,它是 Spring 提供的,只要配置了这个 Bean,在项目启动时,它会扫描所有 Advisor 类型的 Bean,注入到 IoC 容器,再用这些 Advisor Bean 给每个符合条件的 Bean 创建代理类。它利用了接口InstantiationAwareBeanPostProcessor的特性,在每个被代理的 Bean 实例化前创建它们的代理类并返回,所以这些被代理的 Bean 保存在 IoC 容器中的是它们的代理类对象。当这些 Bean 的方法满足切点匹配时,会调用代理它们的 Advisor 对象的成员Advice advice的invoke方法。
举个例子,若访问带@RequiresRoles的 Controller 接口,角色判断的过程是: CglibAopProxy.intercept() -> ReflectiveMethodInvocation.proceed() -> AopAllianceAnnotationsAuthorizingMethodInterceptor.invoke() -> AnnotationsAuthorizingMethodInterceptor.assertAuthorized() -> RoleAnnotationMethodInterceptor.assertAuthorized() -> RoleAnnotationHandler.assertAuthorized()
其中,AopAllianceAnnotationsAuthorizingMethodInterceptor就是第一个 BeanAuthorizationAttributeSourceAdvisor的成员,它自己的成员又包含如下;
1  | Collection<AuthorizingAnnotationMethodInterceptor> methodInterceptors = = new ArrayList<AuthorizingAnnotationMethodInterceptor>(5);  | 
PS: AopAllianceAnnotationsAuthorizingMethodInterceptor 给这个成员赋值时,在它的父类构造方法中按上面的代码赋值了一遍,在它自己的构造方法中又按这样赋值一遍。
AnnotationsAuthorizingMethodInterceptor是AopAllianceAnnotationsAuthorizingMethodInterceptor的直接父类。
处理UnauthorizedException
如果是使用权限注解,若用户无权限,会抛出UnauthorizedException异常,这个异常默认情况下不会被处理,直接打印堆栈。若要处理,有两种方法。
一是如果要自动跳转无权访问页面,则在配置了无权访问页面的 viewName 后,再这样配置:
1  | 
  | 
二是如果不跳转页面,只返回 JSON 响应时,加一个如下所示的 ControllerAdvice:
1  | 
  | 
顺便一提,在配置 Shiro 时,可能你会配置一个ShiroFilterFactoryBean.setUnauthorizedUrl("/unauth");,这个也是配置403自动跳转页面,但只有你在 Shiro 过滤器链中使用了AuthorizationFilter子类,且这个子类用于权限校验时,才生效。
Shiro 配置
读完上面的认证与授权逻辑,可以得出,要实现一般的认证、授权需求,一般需要自己实现以下两个模块:
- 自定义 Realm,继承自
AuthorizingRealm,实现doGetAuthorizationInfo(授权)和doGetAuthenticationInfo(认证)方法。这个必须要实现 - 自定义 Filter,根据实际需要继承 Shiro 已有的 Filter 再自定义。这个可以不实现
 
配置 Shiro 时,一般配置以下内容,可以满足大部分认证授权需求:
1  | 
  | 
禁用Session
关于是否启用 Session,有两个地方控制,一是DefaultSessionStorageEvaluator的属性sessionStorageEnabled,二是DefaultWebSessionManager的属性sessionIdCookieEnabled。这两个属性默认为 true。
DefaultSessionStorageEvaluator是DefaultSubjectDAO的成员。在用户认证成功后创建新 Subject 后,DefaultSubjectDAO负责把 Subject 中的信息保存到 Subject 中的 Session 对象中,如果没有 Session 对象,就调用DefaultWebSessionManager创建 Session 对象和 sessionId 字符串,将这两者保存到MemorySessionDAO中,并将 sessionId 写到响应头”Set-Cookie”中。
如果DefaultSessionStorageEvaluator的属性sessionStorageEnabled设置为 false,则在用户认证成功后创建新 Subject 后,DefaultSubjectDAO 什么也不做。如果DefaultWebSessionManager的属性sessionIdCookieEnabled设置为 false,虽然还会创建 Session,但最后不会将 sessionId 写到响应头中。
如果要禁用 Session,可以只设置DefaultSessionStorageEvaluator的属性sessionStorageEnabled为 false。代码如下:
1  | // 配置SecurityManager  | 
配置缓存
上面说到的 AuthenticatingRealm/AuthorizingRealm 在获取 AuthenticationInfo/AuthorizationInfo 时,都提到它们会先尝试从缓存中取。Shiro 默认情况下没有配置缓存。如果要配置缓存,代码如下:
1  | // 配置SecurityManager  | 
可以看到,我们将RedisCacheManager设置到了DefaultWebSecurityManager中,这个动作会同时将 RedisCacheManager 设置到 DefaultWebSecurityManager 已保存的 Realm 中。所以当 AuthenticatingRealm/AuthorizingRealm 获取 CacheManager 时,是可以直接取到它的。
这里使用的RedisCacheManager是一个开源项目,不是 Shiro 提供的。它实现了 Shiro 的接口CacheManager的getCache方法(也只有这一个方法)。实现代码如下:
1  | public <K, V> Cache<K, V> getCache(String name) throws CacheException {  | 
所以,返回的缓存类是RedisCache,这个类实现了 Shiro 接口Cache<K, V>,是真正直接与 Redis 交互的类。
使用这个开源项目的注意点:
AuthenticatingRealm/AuthorizingRealm 默认都是将PrincipalCollection principals直接作为缓存的 key,这个体现在 AuthorizingRealm 的下面这个方法(AuthenticatingRealm 只是方法名不一样):
1  | protected Object getAuthorizationCacheKey(PrincipalCollection principals) {  | 
如果我们就打算这么用,那么上面配置代码中的一句redisCacheManager.setPrincipalIdFieldName("userId");非常关键,因为这句话是告诉 RedisCache 要从 principal 对象中取”userId”属性作为 redisKey。所以,我们在用户认证成功后,AuthenticationInfo 中保存的 principal 对象中一定要有”userId”属性。
如果我们不想用PrincipalCollection类型作为缓存 key,就重写这个方法,例如;
1  | /**  | 
这样的话,就不需要配置redisCacheManager.setPrincipalIdFieldName("userId");了,RedisCache 会直接用 userId 作为 redisKey。
PS: RedisCache 从外部传入的“key”中获取缓存 key 的逻辑:
1  | private Object getRedisCacheKey(K key) {  |