独立完成系统开发七:安全管理之鉴权
由于在MyAdmin项目中我们是通过Shiro然后结合jwt来实现基于token的方式进行认证的。但是默认情况下,Shiro 的 SecurityManager 使用 session 来存储 Subjec 的身份 (PrincipalCollection) 和身份验证状态(subject.isAuthenticated()) ,而我们要使用token的方式那么就得将session禁用。在禁用session之后由于无法从session中获取数据那么shiro的鉴权就会受到影响。因为鉴权需要依赖于session或subject中的PrincipalCollection。
在这篇文章中主要介绍系统中鉴权部分的实现。
principals介绍
这里对principals做一下介绍,可能会有很多童鞋对这个理解有点模糊。principals在shiro中对应的接口为PrincipalCollection
Principal:他其实就是一个唯一标识,用于标识一个登陆的用户
1)可以是uuid
2)数据库中的主键
3)LDAP UUID或静态DN
4)在所有用户帐户中唯一的字符串用户名、邮箱、身份证等值
在我们的realm中,我们通常都会直接继承AuthorizingRealm
,然后重写doGetAuthenticationInfo
方法和doGetAuthorizationInfo
方法。在登录的时候会调用doGetAuthenticationInfo
对用户的信息进行验证,在这个方法中我们需要返回一个AuthenticationInfo
对象,通常会返回一个他的子类:SimpleAuthenticationInfo
,在创建SimpleAuthenticationInfo的时候我们就需要传入principals。
而在鉴权的时候,程序需要获取当前用户所拥有的的权限及角色那么就会调用realm的doGetAuthorizationInfo
,而doGetAuthorizationInfo
会传入一个参数,这个参数就是principals
,所以在doGetAuthorizationInfo中我们就可以通过principals来获取用户所拥有的权限以及角色了。
所以principals是很重要的,没有principals那么我们在鉴权的时候就无法获取到当前用户所拥有的权限以及角色,所以就无法实现鉴权了
Shiro中原有鉴权的实现
鉴权的方式
Shiro中提供了3种方式进行权限控制分别是:
- 通过编程的方式
- 权限的控制可以基于
- 角色(Role):并提供了
hasRole*
和checkRole*
相关方法,他们的区别是hasRole返回boollean,而checkRole在验证失败时会抛出AuthorizationException
异常 - 权限码(Permission):并提供了
isPermitted*
和checkPermission*
相关方法,区别和角色相关方法的区别一致。由于Permission默认实现为WildcardPermission
,所以权限码的格式为:
号分割,并且冒号分割后每组还可以通过,
号分割指定多个
- 角色(Role):并提供了
- 权限的控制可以基于
- 通过注解,其实最后也是通过编程的方式
- 要使用注解那么在项目中得开启AOP
- 它提供的注解包括
- @RequiresAuthentication:要求当前Subject在当前会话期间已经通过身份验证,相当于
subject.isAuthenticated()==true
- @RequiresGuest:当前用户没有经过身份验证,也没有被之前的会话记住,也就是当前不能存在principals
- @RequiresUser:要求当前用户必须已经是登录了的或之前被RememberMe记住,也就是当前必须存在principals
- @RequiresPermissions(“权限码1:权限码2”):判断当前用户是否有一个或多个权限,相当于
subject.isPermitted("权限码1:权限码2")
- @RequiresRoles(“角色”):判断当前用户是否拥有指定角色,相当于
subject.hasRole("角色")
- @RequiresAuthentication:要求当前Subject在当前会话期间已经通过身份验证,相当于
- 通过jsp标签,这个可以忽略,属于jsp的时代已经过去了,现在基本不会有新项目使用jsp了,当然Servlet必须了解哈
鉴权过程
鉴权过程其实和登录过程类似
- 当通过subject调用鉴权相关方法的时候他会从subject或session中获取principals,如果没有获取到直接返回false,如果存在则会委托给
securityManager
- 在securityManager中会将操作委托给
Authorizer
实例,默认Authorizer的实现为ModularRealmAuthorizer
- 然后在ModularRealmAuthorizer中他会获取所有Realm并检查每个Realm,看它是否实现了
Authorizer
接口。之后在调用Realm中对应的鉴权方法。 - 通常我们自定义的Realm都会继承自
AuthorizingRealm
但并不会去重写相关的鉴权方法,所以他就会调用AuthorizingRealm中对应的鉴权方法 - 在AuthorizingRealm中他会将我们传入的字符串转为
Permission
实例,然后再调用getAuthorizationInfo
方法通过principals
获取AuthorizationInfo
。 - 在getAuthorizationInfo方法中他首先会从缓存中获取,如果没有那么就会调用子类的
doGetAuthorizationInfo
方法获取AuthorizationInfo。而doGetAuthorizationInfo就是我们自定义Realm中重写的方法。所以就会调用我们自定义Realm中的逻辑。在Realm中通过传入的principals获取当前用户所拥有的权限及角色并构建AuthorizationInfo之后返回 - 拿到AuthorizationInfo之后他会将里面的权限以及角色信息转成
Permission
,默认Permission的实现为WildcardPermission
。之后在将我们鉴权时传递的Permission和通过AuthorizationInfo中获取的Permission进行匹配。由于Permission的实现为WildcardPermission,所以对比逻辑在WildcardPermission的implies
方法。 - 在
WildcardPermission
中存储权限码的时候默认他会根据:
将权限码分割为多组,之后每组在根据,
进行分割,结构为List<Set<String>>
。对比的时候两个Permission会一组一组对比(就是将list中的每个元素进行对比,只不过每个元素的类型为set),只有被对比的那一组所有的元素都被包含(set元素是包含关系)才认为匹配。当然如果要定义自己的对比规则我们可以自定义Permission
以及PermissionResolver
。
shiro鉴权改造
从上面的鉴权过程可以看到,当我们通过subject调用鉴权相关方法的时候他会从当前subject(默认实现为DelegatingSubject
)或session中获取principals,如果没有获取到直接返回false。由于在项目中我们使用了shiro结合jwt来实现基于token的方式进行认证并禁用了session。所以正常情况下是无法获取到principals(不过需要注意的是当我们登录后进行操作时是可以从DelegatingSubject中获取principals的,但如果是一个新的请求过来那么就无法获取),没有principals我们就无法在我们的realm中通过principals获取用户所拥有的权限以及角色。
因为使用token的方式后我们可以将principals设置到token中,所以当接收到请求后我们可以直接从token中获取到principals,所以问题就在于在调用鉴权相关方法的时候如何跟principals关联起来。对于这个问题目前我想到的方案有两个:
- 第一个方案:因为鉴权方法我们通常通过subject进行调用,在subject中会自动获取principals然后再将请求委托给securityManager。而subject中获取principals的逻辑为先从session中获取如果没有则从subject中获取,所以我们可以自定义subject,然后再subject中设置principals。其中subject的实现类
DelegatingSubject
中获取principals的代码为-
public PrincipalCollection getPrincipals() { // 从session中获取 List<PrincipalCollection> runAsPrincipals = getRunAsPrincipalsStack(); // 这里this.principals为DelegatingSubject类中的principals return CollectionUtils.isEmpty(runAsPrincipals) ? this.principals : runAsPrincipals.get(0); }
- 所以当我们在subject实现类中设置principals后,其他需要获取principals的操作就可以正常操作了
- 自定义subject的操作官网有相关介绍。自定义subject我们只需要自己构建subject然后再将subject与线程进行关联就可以了。所以我们可以在我们自定义的过滤器中当认证成功后创建自定义subject并关联到当前线程
-
// 构建principal SimplePrincipalCollection principalCollection = new SimplePrincipalCollection(user.getUserId(), "myRealm"); // 创建subject并设置principal Subject subject = new Subject.Builder().principals(principalCollection).buildSubject(); ThreadState threadState = new SubjectThreadState(subject); // 将subject与当前线程绑定 threadState.bind();
-
-
- 第二个方案:在调用鉴权方法的时候将principals传入,因为subject的调用最终都会委托给
securityManager
,而securityManager又会将请求委托给Authorizer
组件,鉴权相关方法是在Authorizer
接口中定义的。并且接口方法中要求传入principals和权限码,例如boolean isPermitted(PrincipalCollection principals, String permission);
所以我们完全可以直接通过securityManager
来调用鉴权方法并传入principals和权限码。这样就不用涉及到subject了。但是这样也就意味着我们无法使用shiro中提供的鉴权注解,因为默认鉴权注解中调用鉴权方法是通过subject进行调用的。如果使用subject调用那么就会判断subject以及session中是否存在principals,所以如果使用这个方案我们需要自定义注解以及添加自定义注解鉴权的操作,具体实现在下面介绍
这两种方案都可以实现。其中第一种方式很简单,自定义subject之后就可以正常的使用shiro了。而第二种方式虽然麻烦一点但是会灵活我们可以在鉴权的时候添加一些额外的操作,例如控制鉴权的关闭和开启、当鉴权发生错误时获取需要鉴权的相关接口的信息并记录日志等等。在MyAdmin中两种我都试过最后选择了第二种因为第二种我们可以在鉴权时自定义一些操作会灵活很多。当然如果你不想折腾选择第一种也是没有问题的。所以下面说一下第二种的实现方案
先说一下第二种鉴权实现的思路:
首先如果使用第一种方案,我们添加自定义subject之后,在使用鉴权时通常都是使用shiro自带的鉴权注解,而鉴权注解是通过AOP实现的。通过AOP在目标方法执行前调用shiro中提供的鉴权相关方法。而在AOP中调用鉴权方法时是通过subject进行调用的。而我们的目标是跳过subject直接通过securityManager进行调用。所以我们就可以仿照他的权限注解实现自定义一个权限注解
所以第二种方案的实现逻辑是,首先我们需要自定义一个权限注解例如@Permission
,如果某个方法上添加了@Permission(AccessCode)
。那么程序会自动根据定义的AccessCode
权限编码进行权限的验证。在AOP中@Permission注解主要作为一个切点以及传递所需的权限编码,之后在AOP中获取注解中传递的权限编码然后通过securityManager调用shiro中的鉴权方法,在根据结果判断是否需要执行目标方法。当然期间我们可以添加一些额外的操作
还有就是在MyAdmin中权限编码并没有使用shiro中提供的权限编码格式,而是直接通过一个字符串来表示权限编码。
默认在shiro中权限编码Permission的格式为:p1:p2,p3
。他通过:
将权限码进行分组,如果一组中有多个权限那么可以通过,
分割,例如对于用户有查询和新增权限user:query,add
。并且在这些权限码中如果拥有所有权限可以通过*
表示,例如拥有用户的所有权限可以表示为user:*
这种写法相比于直接通过字符串表示(user:query,add
直接通过字符串表示就是user_query
和user_add
),编写起来会简单很多,但是对于授权时的存储或构建AuthorizationInfo
时却变麻烦了。并且shiro中默认的格式我觉得有时候还不是很好理解。为了更简单我就选择了直接通过字符串来表示权限编码。直接通过字符串表示权限编码在权限对比时WildcardPermission
也是完全支持的,所以我们并不需要添加自定义的权限对比的逻辑。当然如果使用shiro默认的格式WildcardPermission
的对比性能可能会好一点,不过差别不大基本可以忽略。当然如果我们连权限编码都不想要我们可以直接将接口请求的url作为权限编码,这样也是可以实现的。
了解思路后实现就很简单啦,下面是具体实现:
Permission注解
/**
* 功能描述: 权限注解 用于检查权限 规定访问权限
* @author cdfan
* @date 2020/3/16 15:52
*/
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface Permission {
/**
* 权限编码,
* 如果有多个:
* 在要求只要拥有其中某个权限就可以通过时:权限码之间通过 | 符号来连接
* 例如:@Permission("role_edit|role_add")
* 在要求拥有指定的所有权限才可以通过时:权限码之间通过 & 符号来连接
* 例如:@Permission("role_edit&role_add")
* 注意不支持|和&混用,因为好像不存在那种需求,而且官网的鉴权也未提供这种功能
*/
String value() default "";
/**
* 角色编码
* 使用注解时加上这个值表示限制必须要拥有某些角色的才可以访问对应的资源
*/
String[] roles() default {};
}
AOP权限验证
/**
* 功能描述: AOP 权限自定义检查
* @author cdfan
* @date 2020/4/24 14:10
*/
@Aspect
@Component
@Order(-1)
public class PermissionAop {
@Autowired
private MyAdminProperties myAdminProperties;
@Autowired
private PermissionCheck permissionCheck;
@Pointcut(value = "@annotation(com.myadminmain.core.annotion.Permission)")
private void cutPermission() {
}
@Around("cutPermission()")
public Object doPermission(ProceedingJoinPoint point) throws Throwable {
// 判断是否需要启用权限验证
if(myAdminProperties.isPermission()){
MethodSignature ms = (MethodSignature) point.getSignature();
Method method = ms.getMethod();
Permission permission = method.getAnnotation(Permission.class);
String accessCode = permission.value();
String[] roles = permission.roles();
boolean codePermission = false;
boolean rolesPermission = false;
//验证权限码
if(!ObjectUtils.isEmpty(accessCode)){
//检查当前登录用户是否拥有当前请求的权限
codePermission = permissionCheck.checkPermission(accessCode);
}
if (roles.length != 0) {
//检查指定角色
rolesPermission = permissionCheck.checkRole(roles);
}
if (codePermission||rolesPermission) {
return point.proceed();
} else {
LogRecord.log(point, "权限不足", new NotPermissionException("权限不足"));
throw new NotPermissionException("权限不足");
}
}else{
return point.proceed();
}
}
}
权限验证实现
/**
* 功能描述: 权限自定义检查
* @author cdfan
* @date 2020/4/24 14:10
*/
@Component
public class PermissionCheck {
/**
* 功能描述: 检查是否拥有指定角色
* @param roles 角色集合
* @return boolean
* @author cdfan
* @date 2020/5/22 17:25
*/
public boolean checkRole(String[] roles) {
ShiroUser shiroUser = ShiroUtil.getUser();
if (null == shiroUser) {
return false;
}
SimplePrincipalCollection principalCollection = new SimplePrincipalCollection(shiroUser.getUserId(), "myRealm");
//只要用户所拥有的角色中包含这个指定的角色就可以了
for (String role : roles) {
if (SecurityUtils.getSecurityManager().hasRole(principalCollection, role)) {
return true;
}
}
return false;
}
/**
* 功能描述: 检查是否拥有权限
* @param accessCode 权限码
* @return boolean
* @author cdfan
* @date 2020/5/22 17:25
*/
public boolean checkPermission(String accessCode) {
ShiroUser shiroUser = ShiroUtil.getUser();
if (null == shiroUser) {
return false;
}
SimplePrincipalCollection principalCollection = new SimplePrincipalCollection(shiroUser.getUserId(), "myRealm");
//如果要求有多个权限
if (accessCode.contains("|") | accessCode.contains("&")) {
String[] accessCodes;
if (accessCode.contains("|")) {
accessCodes = accessCode.split("\\|");
boolean[] permitteds = SecurityUtils.getSecurityManager().isPermitted(principalCollection, accessCodes);
for (boolean permitted : permitteds) {
if (permitted) {
return true;
}
}
return false;
} else {
accessCodes = accessCode.split("&");
return SecurityUtils.getSecurityManager().isPermittedAll(principalCollection, accessCodes);
}
} else {
return SecurityUtils.getSecurityManager().isPermitted(principalCollection, accessCode);
}
}
}
Realm提供用户权限及角色
由于我没有使用shiro默认的权限码格式,而是直接通过一个code字符串来表示一个权限,所以直接将查询出来的权限编码设置到AuthorizationInfo
中就可以了,当然存储的时候也是直接存储并不需要进行转换
/**
* 获取权限
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
IShiro shiroFactory = ShiroFactroy.me();
Integer userId = (Integer) principals.getPrimaryPrincipal();
//用户所的菜单权限码集合
List<String> permissions = shiroFactory.findPermissionsByUserId(userId);
//用户所拥有的角色集合
List<String> roles = shiroFactory.findRoleByUserId(userId);
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.addStringPermissions(permissions);
info.addRoles(roles);
return info;
}
使用
使用的时候我们只需要在需要进行鉴权的方法上添加@Permission("权限码")
或@Permission("角色编码")
注解就可以了,如果有多个编码可以通过&
和|
表示对应的逻辑操作
例如:
/**
* 功能描述: 根据主键查询-部门
* @param deptId 部门id
* @return com.myadminmain.sys.dept.entity.Dept
* @author cdfan
* @date 2020-03-22
*/
@Permission("dept_query")
@RequestMapping(value="/deptInfo/{deptId}", method= RequestMethod.GET)
public ResultData<Dept> get(@PathVariable("deptId") Integer deptId) {
return new ResultData<Dept>(deptService.getById(deptId));
}
到此,系统中shiro的改造算是完成了。接下来就可以基于token的方式愉快的使用了。并且因为他是基于token的,不用关心session的共享问题,所以对于分布式和负载是有很好的支持的,当后台服务器压力很大,那么我们只需要在加一台服务器然后前端配置一下负载就可以了。