Shiro 认证与授权流程

前言

只要是权限校验框架,就离不开两个步骤:认证(Authentication)和授权(Authorization)。认证即“用户登录”,授权即“允许用户访问目标URI”。这篇博客介绍了Shiro 1.3.2版本认证与授权这两个过程的执行链条,未涉及到更高级的功能(如单点登录、RememberMe)。

认证

Shiro 认证的入口很简单,就是下面三行代码:

1
2
3
UsernamePasswordToken token = new UsernamePasswordToken(username, password);
Subject subject = SecurityUtils.getSubject();
subject.login(token);

这三行代码写在哪里由我们自己定。

第一行,是将用户输入的 username、password 封装成UsernamePasswordToken对象,这个类的顶级父类接口是AuthenticationToken,后面进入Realm中被认证的就是它。

第二行调用SecurityUtils.getSubject()取一个Subject对象,这个方法源码如下:

1
2
3
4
5
6
7
8
public static Subject getSubject() {
Subject subject = ThreadContext.getSubject();
if (subject == null) {
subject = (new Subject.Builder()).buildSubject();
ThreadContext.bind(subject);
}
return subject;
}

在认证代码这里,从 ThreadContext 是可以取到 subject 对象的,在这里 subject 对象使用的实现类是WebDelegatingSubject。ThreadContext 是用ThreadLocal<Map<Object, Object>> resources保存 subject 对象。

问题:ThreadContext 中的 subject 对象怎么来的?简单地说,是用户请求经过 SpringShiroFilter 时,SpringShiroFilter 根据请求创建 subject 并设置到 ThreadContext 中的,详细见博客《SpringShiroFilter原理》的“工作原理-创建 Subject 对象”一节。

第三行subject.login(token)这个方法便是完整的用户认证的过程。步骤简述如下:

  1. 包含 username 和 password 的UsernamePasswordToken对象进入Subject.login()方法
  2. 在 Subject.login() 中,调用SecurityManager.login()方法,使用的 SecurityManager 实现类是在“Shiro配置文件”指定的,一般是DefaultWebSecurityManager
  3. 在 SecurityManager.login() 中,调用Authenticator.authenticate()方法。默认调用到 Authenticator 的实现类ModularRealmAuthenticator
  4. 在 ModularRealmAuthenticator.authenticate() 中,当其成员Collection<Realm> realms只有一个元素时(有多个元素的情况此处不提),就调用这一个RealmgetAuthenticationInfo()方法。”realms”的元素数量取决于在“Shiro配置文件”中给 SecurityManager 设置了几个 Realm 实现类
  5. Realm 接口的 getAuthenticationInfo 方法由子类AuthenticatingRealm实现,这个类定义了钩子方法doGetAuthenticationInfo,交给我们自定义 Realm 实现。这个方法就是根据入参AuthenticationToken对象(UsernamePasswordToken 的接口类型),查询用户原信息,校验用户密码等,即进行所谓的认证。认证成功会返回AuthenticationInfo对象,一般使用实现类SimpleAuthenticationInfo
  6. 若认证失败,可抛出AuthenticationException及其子类的异常,包括:DisabledAccountException(禁用的帐号)、UnknownAccountException(错误的帐号)、IncorrectCredentialsException (错误的凭证)等。这些异常可在调用subject.login(token)的地方捕获并处理
  7. 认证成功后,Realm 接口的 getAuthenticationInfo 方法返回的 AuthenticationInfo 对象一直向上传递到 SecurityManager.login() 方法
  8. 在 SecurityManager.login() 中,获得 AuthenticationInfo 后,会创建一个新 Subject 对象。这个新 Subject 对象中保存了代表用户身份的PrincipalCollection principals,这个 principals 的来源就是 AuthenticationInfo
  9. SecurityManager.login() 将创建的“新subject”向上传递到 subject.login() 方法,“新subject”中包含的principalsauthenticated(已认证标志,认证成功后设置为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
2
3
4
5
public interface AuthenticationInfo extends Serializable {
PrincipalCollection getPrincipals();

Object getCredentials();
}

其中,”getPrincipals”返回的代表了用户身份,是个集合对象,说明支持一个用户多个“身份”。这个“身份”可以是字符串,也可以是一个 Object 对象。”getCredentials”返回的代表了用户身份的密码,一般保存的是用户账号的密码字符串。

AuthenticatingRealm.getAuthenticationInfo()方法中,先尝试从“缓存”中取 AuthenticationInfo,若取出的 info 为 null,则调用本类的doGetAuthenticationInfo方法,这个方法由子类实现。从 doGetAuthenticationInfo 方法返回 info 后,会把它保存到“缓存”中。最后,会判断这个 info 与 AuthenticationToken 中的 credentials 是否一致,不一致则认证失败,一致则返回 info 对象。

AuthenticatingRealm 中的“缓存”,是用下面这两个方法取的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private Cache<Object, AuthenticationInfo> getAvailableAuthenticationCache() {
Cache<Object, AuthenticationInfo> cache = getAuthenticationCache(); // 取属性authenticationCache,默认为null
boolean authcCachingEnabled = isAuthenticationCachingEnabled(); // 返回值默认false
if (cache == null && authcCachingEnabled) {
cache = getAuthenticationCacheLazy(); // 懒加载缓存
}
return cache;
}

private Cache<Object, AuthenticationInfo> getAuthenticationCacheLazy() {
if (this.authenticationCache == null) {
log.trace("No authenticationCache instance set. Checking for a cacheManager...");

CacheManager cacheManager = getCacheManager(); // 取属性cacheManager,默认为null
if (cacheManager != null) {
String cacheName = getAuthenticationCacheName(); // cacheName有默认值
log.debug("CacheManager [{}] configured. Building authentication cache '{}'", cacheManager, cacheName);
this.authenticationCache = cacheManager.getCache(cacheName); // 调用cacheManager.getCache()方法获取缓存工具对象
}
}

return this.authenticationCache;
}

因为在默认配置下,AuthenticatingRealm 不会使用缓存,所以用户认证时一定会进入 doGetAuthenticationInfo 方法。【默认不使用缓存的原因应该是,用户一般不会多次重复登录。

与Spring Security的区别

  1. Shiro 的认证入口在subject.login(token)方法。Spring Security 的认证入口是UsernamePasswordAuthenticationFilter.doFilter()方法
  2. Shrio 的调用链条简单,需要实现的就是AuthenticatingRealm.doGetAuthenticationInfo()方法,在这里实现查询原用户信息、校验用户名密码这2个必要步骤的逻辑。Spring Security 把这两个步骤分开在了UserDetailsServiceAuthenticationProvider上,要分别实现(不用Spring默认配置的话)
  3. Shiro 没有做默认实现类和自动装配。Spring Security 提供了几乎所有接口的默认实现类和自动装配,所以只要引入jar包就能用

授权

Shiro 实现授权校验的方式有多种:

  1. 在“Shiro配置文件”为需要授权的 URI 配置授权类的过滤器,即AuthorizationFilter的子类,如PermissionsAuthorizationFilter
  2. 在需要授权的接口 Controller 方法上使用@RequiresPermissions@RequiresRoles注解

无论使用哪种方式,授权校验时调用的都是Subject类中的这些方法:

权限校验方法:

1
2
3
4
5
6
7
8
9
10
boolean isPermitted(String permission);
boolean isPermitted(Permission permission);
boolean[] isPermitted(String... permissions);
boolean[] isPermitted(List<Permission> permissions);
boolean isPermittedAll(String... permissions);
boolean isPermittedAll(Collection<Permission> permissions);
void checkPermission(String permission) throws AuthorizationException;
void checkPermission(Permission permission) throws AuthorizationException;
void checkPermissions(String... permissions) throws AuthorizationException;
void checkPermissions(Collection<Permission> permissions) throws AuthorizationException;

角色校验方法:

1
2
3
4
5
6
boolean hasRole(String roleIdentifier);
boolean[] hasRoles(List<String> roleIdentifiers);
boolean hasAllRoles(Collection<String> roleIdentifiers);
void checkRole(String roleIdentifier) throws AuthorizationException;
void checkRoles(Collection<String> roleIdentifiers) throws AuthorizationException;
void checkRoles(String... roleIdentifiers) throws AuthorizationException;

这些方法由DelegatingSubject实现,每个方法的实现方式就是调用成员SecurityManager的同名同返回类型方法,以下面这个方法为例:

1
2
3
public boolean isPermitted(String permission) {
return hasPrincipals() && securityManager.isPermitted(getPrincipals(), permission);
}

方法参数上,SecurityManager 的同名方法要多传一个PrincipalCollection principals,DelegatingSubject 会取出自己的 principals 传给 SecurityManager。如果配置启用“Session”,已登录用户的请求经过 SpringShiroFilter 时,SpringShiroFilter 会根据用户请求取出 session,再从 session 中取出 principals,设置到这里的 subject 对象。


如果禁用“Session”,那么在调用这些方法前,需要自己往 subject 对象中设置 principals 再调用这些方法。

往 subject 中设置 principals,并替换原有的由 SpringShiroFilter 创建的 subject 对象的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 旧subject
Subject subject = SecurityUtils.getSubject();

// 这里的UsernamePasswordToken、SimpleAuthenticationInfo中设置的 principal=userId,后面会设置到新创建的subject对象中
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(userId, (String) null);
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(userId, null, getName());
SecurityManager securityManager = SecurityUtils.getSecurityManager();

// 把UsernamePasswordToken、SimpleAuthenticationInfo设置到SubjectContext中
SubjectContext context = new DefaultSubjectContext();
context.setAuthenticated(true);
context.setAuthenticationToken(usernamePasswordToken);
context.setAuthenticationInfo(info);
context.setSubject(subject);

// 调用securityManager方法,根据SubjectContext创建新subject
subject = securityManager.createSubject(context);
// ThreadContext中保存的是旧subject,换成新的
ThreadContext.bind(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
2
boolean isPermitted(PrincipalCollection principals, String permission);
boolean isPermitted(PrincipalCollection principals, Permission permission);

判断用户是否具有某个权限。String 类型的会先转成Permission类型的再判断,这点同下面的方法。

1
2
boolean[] isPermitted(PrincipalCollection principals, String... permissions);
boolean[] isPermitted(PrincipalCollection principals, List<Permission> permissions);

判断用户是否具有参数列出的权限,返回每个权限的校验结果数组。
PS: 实际上是对每个权限遍历调用isPermitted(PrincipalCollection principals, Permission permission)

1
2
boolean isPermittedAll(PrincipalCollection principals, String... permissions);
boolean isPermittedAll(PrincipalCollection principals, Collection<Permission> permissions);

判断用户是否具有参数列出的权限,与上一个不同点是,只有每个权限校验都返回 true,这个方法才返回 true。
PS: 实际上是对每个权限遍历调用isPermitted(PrincipalCollection principals, Permission permission)

1
2
void checkPermission(PrincipalCollection principals, String permission) throws AuthorizationException;
void checkPermission(PrincipalCollection principals, Permission permission) throws AuthorizationException;

作用同isPermitted方法,不同点是,若用户无权限,会抛出UnauthorizedException
PS: 实际上也是调用isPermitted(PrincipalCollection principals, Permission permission)

1
2
void checkPermissions(PrincipalCollection principals, String... permissions) throws AuthorizationException;
void checkPermissions(PrincipalCollection principals, Collection<Permission> 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
2
void checkRoles(PrincipalCollection principals, Collection<String> roleIdentifiers) throws AuthorizationException;
void checkRoles(PrincipalCollection principals, String... roleIdentifiers) throws AuthorizationException;

对参数列出的所有角色,遍历调用上一个checkRole方法,只要校验出一个无角色,就抛出UnauthorizedException

总结:

通过这些授权校验方法的源码,我们可以看出,权限校验方法的基础是isPermitted(PrincipalCollection principals, Permission permission),角色校验方法的基础是hasRole。AuthorizingRealm 对这两个方法的实现思路相同,如下源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 权限
public boolean isPermitted(PrincipalCollection principals, Permission permission) {
AuthorizationInfo info = getAuthorizationInfo(principals);
return isPermitted(permission, info);
}
protected boolean isPermitted(Permission permission, AuthorizationInfo info) {
Collection<Permission> perms = getPermissions(info);
if (perms != null && !perms.isEmpty()) {
for (Permission perm : perms) {
if (perm.implies(permission)) {
return true;
}
}
}
return false;
}

// 角色
public boolean hasRole(PrincipalCollection principal, String roleIdentifier) {
AuthorizationInfo info = getAuthorizationInfo(principal);
return hasRole(roleIdentifier, info);
}
protected boolean hasRole(String roleIdentifier, AuthorizationInfo info) {
return info != null && info.getRoles() != null && info.getRoles().contains(roleIdentifier);
}

即校验权限/角色前,都要先调用getAuthorizationInfo方法获取AuthorizationInfo对象,再用这个AuthorizationInfo对象进行校验。

AuthorizationInfo和getAuthorizationInfo方法

上面讲到认证逻辑时,AuthenticatingRealm.getAuthenticationInfo()返回的对象是AuthenticationInfo。这里,AuthorizingRealm.getAuthorizationInfo()返回的对象是AuthorizationInfo,一般使用实现类SimpleAuthorizationInfo

AuthorizationInfo接口只定义了3个方法:

1
2
3
4
5
6
7
public interface AuthorizationInfo extends Serializable {
Collection<String> getRoles();

Collection<String> getStringPermissions();

Collection<Permission> getObjectPermissions();
}

很明显,这个对象就是用来保存用户的“角色(字符串)”、“权限(字符串)”、“权限(Permission类型)”。

AuthorizingRealm.getAuthorizationInfo()方法中,先尝试从“缓存”中取 AuthorizationInfo,若取出的 info 为 null,则调用本类的doGetAuthorizationInfo方法,这个方法由子类实现。从 doGetAuthorizationInfo 方法返回 info 后,会把它保存到“缓存”中。最后返回 info 对象。

AuthorizingRealm 取“缓存”的方法与 AuthenticatingRealm 完全一致(源码如下),不同的是,AuthorizingRealm 是默认会使用缓存的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private Cache<Object, AuthorizationInfo> getAvailableAuthorizationCache() {
Cache<Object, AuthorizationInfo> cache = getAuthorizationCache(); // 取属性authorizationCache,默认为null
if (cache == null && isAuthorizationCachingEnabled()) { // isAuthorizationCachingEnabled()默认返回true,这点与AuthorizingRealm不同
cache = getAuthorizationCacheLazy();
}
return cache;
}

private Cache<Object, AuthorizationInfo> getAuthorizationCacheLazy() { // 这个方法省略了日志打印部分
if (this.authorizationCache == null) {

CacheManager cacheManager = getCacheManager();
if (cacheManager != null) {
String cacheName = getAuthorizationCacheName();
this.authorizationCache = cacheManager.getCache(cacheName);
} else {
}
}
return this.authorizationCache;
}

因为 AuthorizingRealm 默认会使用缓存,所以如果有给 AuthorizingRealm 配置缓存,每次权限校验就不都会进入到 doGetAuthorizationInfo 方法了。

权限字符串的转换

在我们自定义实现的doGetAuthorizationInfo方法中,一般是从数据库中取出用户的权限字符串保存到AuthorizationInfo中。但是我们知道,进行权限校验时,权限字符串都会被转成Permission类型的权限对象再进行判断。负责这个转换的是 AuthorizingRealm 的成员PermissionResolver,默认使用实现类WildcardPermissionResolver

因为这个原因,Shiro 的权限字符串要遵循WildcardPermissionResolver要求的格式:资源标识符:操作:对象实例ID。如”system:user:update”中,”system:user”是资源标识符,”update”是操作,对象实例ID为空。对象实例ID一般用不到,因为太具体了。

权限字符串支持符号:”:”表示资源/操作/实例的分割;”,”表示操作的分割;”*”表示任意资源/操作/实例。

举例:

  1. 用 system:user:update,delete 验证 system:user:update,system:user:delete 可以,反之不行
  2. 用 system:user:* 验证 system:user:update,delete,add 可以,反之不行
  3. *:delete 等同于 system:user:delete,也等同于 user:delete(区别是资源标识符是一级还是两级)

如果自定义了 PermissionResolver 实现,那么可以自定义将权限字符串转换为 Permission 对象的方法,字符串格式也就可以自己定。

使用权限注解时的配置

前面说到,Shiro 实现授权校验的方式有:

  1. 在“Shiro配置文件”为需要授权的 URI 配置授权类的过滤器,即AuthorizationFilter的子类,如PermissionsAuthorizationFilter
  2. 在需要授权的接口 Controller 方法上使用@RequiresPermissions@RequiresRoles注解

如果使用第二种的注解方式,在“Shiro配置文件中”除了常规的配置,还需要加上以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(org.apache.shiro.mgt.SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}

@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}

第一个 Bean 是AuthorizationAttributeSourceAdvisor是 Shiro 提供的,它间接实现了接口Advisor

第二个 Bean 是DefaultAdvisorAutoProxyCreator,它是 Spring 提供的,只要配置了这个 Bean,在项目启动时,它会扫描所有 Advisor 类型的 Bean,注入到 IoC 容器,再用这些 Advisor Bean 给每个符合条件的 Bean 创建代理类。它利用了接口InstantiationAwareBeanPostProcessor的特性,在每个被代理的 Bean 实例化前创建它们的代理类并返回,所以这些被代理的 Bean 保存在 IoC 容器中的是它们的代理类对象。当这些 Bean 的方法满足切点匹配时,会调用代理它们的 Advisor 对象的成员Advice adviceinvoke方法。

举个例子,若访问带@RequiresRoles的 Controller 接口,角色判断的过程是: CglibAopProxy.intercept() -> ReflectiveMethodInvocation.proceed() -> AopAllianceAnnotationsAuthorizingMethodInterceptor.invoke() -> AnnotationsAuthorizingMethodInterceptor.assertAuthorized() -> RoleAnnotationMethodInterceptor.assertAuthorized() -> RoleAnnotationHandler.assertAuthorized()

其中,AopAllianceAnnotationsAuthorizingMethodInterceptor就是第一个 BeanAuthorizationAttributeSourceAdvisor的成员,它自己的成员又包含如下;

1
2
3
4
5
6
Collection<AuthorizingAnnotationMethodInterceptor> methodInterceptors = = new ArrayList<AuthorizingAnnotationMethodInterceptor>(5);
methodInterceptors.add(new RoleAnnotationMethodInterceptor()); // 处理@RequiresRoles
methodInterceptors.add(new PermissionAnnotationMethodInterceptor()); // 处理@RequiresPermissions
methodInterceptors.add(new AuthenticatedAnnotationMethodInterceptor());
methodInterceptors.add(new UserAnnotationMethodInterceptor());
methodInterceptors.add(new GuestAnnotationMethodInterceptor());

PS: AopAllianceAnnotationsAuthorizingMethodInterceptor 给这个成员赋值时,在它的父类构造方法中按上面的代码赋值了一遍,在它自己的构造方法中又按这样赋值一遍。

AnnotationsAuthorizingMethodInterceptorAopAllianceAnnotationsAuthorizingMethodInterceptor的直接父类。

处理UnauthorizedException

如果是使用权限注解,若用户无权限,会抛出UnauthorizedException异常,这个异常默认情况下不会被处理,直接打印堆栈。若要处理,有两种方法。

一是如果要自动跳转无权访问页面,则在配置了无权访问页面的 viewName 后,再这样配置:

1
2
3
4
5
6
7
8
@Bean
public SimpleMappingExceptionResolver simpleMappingExceptionResolver() {
SimpleMappingExceptionResolver simpleMappingExceptionResolver = new SimpleMappingExceptionResolver();
Properties properties = new Properties();
properties.setProperty("org.apache.shiro.authz.UnauthorizedException", "unauth"); // unauth是无权访问页面的viewName
simpleMappingExceptionResolver.setExceptionMappings(properties);
return simpleMappingExceptionResolver;
}

二是如果不跳转页面,只返回 JSON 响应时,加一个如下所示的 ControllerAdvice:

1
2
3
4
5
6
7
8
9
10
11
12
13
@ControllerAdvice
public class ControllerExceptionHandler {

@ResponseBody // 响应体是json格式就必须加这个注解
@ExceptionHandler(value = UnauthorizedException.class) // 指定抛出的异常类型
public JSONObject handleException(UnauthorizedException ex) { // 在这个方法中写抛出异常后的处理
System.out.println("=============ControllerExceptionHandler===============");
JSONObject json = new JSONObject();
json.put("code", "1111");
json.put("msg", "当前用户无权限");
return json;
}
}

顺便一提,在配置 Shiro 时,可能你会配置一个ShiroFilterFactoryBean.setUnauthorizedUrl("/unauth");,这个也是配置403自动跳转页面,但只有你在 Shiro 过滤器链中使用了AuthorizationFilter子类,且这个子类用于权限校验时,才生效。

Shiro 配置

读完上面的认证与授权逻辑,可以得出,要实现一般的认证、授权需求,一般需要自己实现以下两个模块:

  1. 自定义 Realm,继承自AuthorizingRealm,实现doGetAuthorizationInfo(授权)和doGetAuthenticationInfo(认证)方法。这个必须要实现
  2. 自定义 Filter,根据实际需要继承 Shiro 已有的 Filter 再自定义。这个可以不实现

配置 Shiro 时,一般配置以下内容,可以满足大部分认证授权需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Configuration
public class ShiroConfig {
// 配置自定义Realm
@Bean
public CustomRealm customRealm() {
return new CustomRealm();
}

// Shiro默认启用session,对session的配置,不是必须配置
@Bean
public DefaultWebSessionManager defaultWebSessionManager() {
DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager();
defaultWebSessionManager.setGlobalSessionTimeout(3600 * 1000);// 会话过期时间,单位:毫秒(在无操作时开始计时)
defaultWebSessionManager.setSessionValidationSchedulerEnabled(true); // 开启定时清理失效会话,如用户直接关闭浏览器
defaultWebSessionManager.setSessionIdCookieEnabled(true); // 设为false将不会设置 Session-Id Cookie
return defaultWebSessionManager;
}

// 配置SecurityManager
@Bean
public DefaultSecurityManager securityManager(CustomRealm customRealm, DefaultWebSessionManager defaultWebSessionManager) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(customRealm);
securityManager.setSessionManager(defaultWebSessionManager);
SecurityUtils.setSecurityManager(securityManager);
return securityManager;
}

// 不要用bean的方式添加自定义filter,会因为bean加载顺序的问题把这个filter放在anonFilter前面
// @Bean
// public PermissionCheckFilter permissionCheckFilter() {
// return new PermissionCheckFilter();
// }

@Bean
public ShiroFilterFactoryBean shiroFilter(DefaultSecurityManager securityManager) {
ShiroFilterFactoryBean factory = new ShiroFilterFactoryBean();
factory.setSecurityManager(securityManager);

factory.setLoginUrl("/login"); // 登录页URI,在重定向到登录页时用到
factory.setUnauthorizedUrl("/unauth"); // 403页面URI,在重定向到403页时用到

// 添加自定义Filter
Map<String, Filter> filtersMap = new LinkedHashMap<String, Filter>();
filtersMap.put("permissionCheckFilter", new PermissionCheckFilter());
factory.setFilters(filtersMap);

Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
// 匹配上的URI走对应的Filter
filterChainDefinitionMap.put("/logout", "logout");
filterChainDefinitionMap.put("/login", "anon");
filterChainDefinitionMap.put("/login/submit", "anon");
filterChainDefinitionMap.put("/getRSAKey", "anon");
filterChainDefinitionMap.put("/unauth", "anon");
filterChainDefinitionMap.put("/*.ico", "anon");
// filterChainDefinitionMap.put("/**", "permissionCheckFilter");
System.out.println(filterChainDefinitionMap);
factory.setFilterChainDefinitionMap(filterChainDefinitionMap);
return factory;
}
}

禁用Session

关于是否启用 Session,有两个地方控制,一是DefaultSessionStorageEvaluator的属性sessionStorageEnabled,二是DefaultWebSessionManager的属性sessionIdCookieEnabled。这两个属性默认为 true。

DefaultSessionStorageEvaluatorDefaultSubjectDAO的成员。在用户认证成功后创建新 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
2
3
4
5
6
7
8
9
10
11
12
13
14
// 配置SecurityManager
@Bean
public DefaultWebSecurityManager securityManager(ShiroRealm myRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(myRealm);

DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);

securityManager.setSubjectDAO(subjectDAO);
return securityManager;
}

配置缓存

上面说到的 AuthenticatingRealm/AuthorizingRealm 在获取 AuthenticationInfo/AuthorizationInfo 时,都提到它们会先尝试从缓存中取。Shiro 默认情况下没有配置缓存。如果要配置缓存,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 配置SecurityManager
@Bean
public DefaultWebSecurityManager securityManager(ShiroRealm myRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(myRealm);

DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);

securityManager.setSubjectDAO(subjectDAO);
//自定义缓存实现,使用redis
securityManager.setCacheManager(redisCacheManager());
return securityManager;
}

/**
* cacheManager 缓存 redis实现
* 使用的是shiro-redis开源插件
*/
public RedisCacheManager redisCacheManager() {
log.info("===============(1)创建缓存管理器RedisCacheManager");
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
//redis中针对不同用户缓存(此处的id需要对应user实体中的id字段,用于唯一标识)
redisCacheManager.setPrincipalIdFieldName("userId");
//用户权限信息缓存时间
redisCacheManager.setExpire(200000);
return redisCacheManager;
}

public RedisManager redisManager() {
log.info("===============(2)创建RedisManager,连接Redis..URL= " + host + ":" + port);
RedisManager redisManager = new RedisManager();
redisManager.setHost(host); // 配置redis信息
redisManager.setPort(Integer.parseInt(port));
redisManager.setDatabase(database);
redisManager.setTimeout(0);
if (!StringUtils.isEmpty(redisPassword)) {
redisManager.setPassword(redisPassword);
}
return redisManager;
}

可以看到,我们将RedisCacheManager设置到了DefaultWebSecurityManager中,这个动作会同时将 RedisCacheManager 设置到 DefaultWebSecurityManager 已保存的 Realm 中。所以当 AuthenticatingRealm/AuthorizingRealm 获取 CacheManager 时,是可以直接取到它的。

这里使用的RedisCacheManager是一个开源项目,不是 Shiro 提供的。它实现了 Shiro 的接口CacheManagergetCache方法(也只有这一个方法)。实现代码如下:

1
2
3
4
5
6
7
8
9
10
public <K, V> Cache<K, V> getCache(String name) throws CacheException {
this.logger.debug("get cache, name=" + name);
Cache cache = (Cache)this.caches.get(name);
if (cache == null) {
cache = new RedisCache(this.redisManager, this.keySerializer, this.valueSerializer, this.keyPrefix + name + ":", this.expire, this.principalIdFieldName);
this.caches.put(name, cache);
}

return (Cache)cache;
}

所以,返回的缓存类是RedisCache,这个类实现了 Shiro 接口Cache<K, V>,是真正直接与 Redis 交互的类。

使用这个开源项目的注意点:

AuthenticatingRealm/AuthorizingRealm 默认都是将PrincipalCollection principals直接作为缓存的 key,这个体现在 AuthorizingRealm 的下面这个方法(AuthenticatingRealm 只是方法名不一样):

1
2
3
protected Object getAuthorizationCacheKey(PrincipalCollection principals) {
return principals;
}

如果我们就打算这么用,那么上面配置代码中的一句redisCacheManager.setPrincipalIdFieldName("userId");非常关键,因为这句话是告诉 RedisCache 要从 principal 对象中取”userId”属性作为 redisKey。所以,我们在用户认证成功后,AuthenticationInfo 中保存的 principal 对象中一定要有”userId”属性。

如果我们不想用PrincipalCollection类型作为缓存 key,就重写这个方法,例如;

1
2
3
4
5
6
7
8
/**
* 重写获取缓存key的方法,原本是用整个PrincipalCollection对象做key,现在改为转为userId字符串做key
*/
@Override
protected Object getAuthorizationCacheKey(PrincipalCollection principals) {
String userId = (String) principals.getPrimaryPrincipal();
return userId;
}

这样的话,就不需要配置redisCacheManager.setPrincipalIdFieldName("userId");了,RedisCache 会直接用 userId 作为 redisKey。

PS: RedisCache 从外部传入的“key”中获取缓存 key 的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private Object getRedisCacheKey(K key) {
if (key == null) {
return null;
} else {
// 默认keySerializer = StringSerializer
return this.keySerializer instanceof StringSerializer ? this.keyPrefix + this.getStringRedisKey(key) : key;
}
}

private String getStringRedisKey(K key) {
String redisKey;
if (key instanceof PrincipalCollection) {
// 根据设置的principalIdFieldName,从外部key对象中取对应属性
redisKey = this.getRedisKeyFromPrincipalIdField((PrincipalCollection)key);
} else {
// 外部key直接作为redisKey
redisKey = key.toString();
}

return redisKey;
}