作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
来回顾一下自定义权限管理:用户即角色这种设计的不足之处:
如果运营告诉你,由于图书信息录入错误率较高,希望让学生也参与校正(对学生开放updateBook接口),你只能改代码,把接口上面的:
@PermissionRequired(value={UserTypeEnum.TEACHER})
改为:
@PermissionRequired(value={UserTypeEnum.TEACHER, UserTypeEnum.STUDENT}, logical = Logical.OR)
如此一来,这个接口就对学生开放权限了。
但上面操作需要修改代码、重新打包部署,如果你们公司没有做持续集成啥的,可能这个需求还要等到夜深人静没什么用户时才能停机重启...
归根结底,@PermissionRequired一开始就设计错了,由于@PermissionRequired的属性是角色(TEACHER等),而我们又把这个注解直接放在具体的方法上(也就是资源),这相当于角色和权限以硬编码的方式绑定了,所以后期要更改权限,也要以硬编码的方式解绑。
解决办法是:@PermissionRequired只代表权限本身,把方法视为资源,这样资源即权限,然后通过t_role_permission中间表随意绑定、解绑某个角色拥有的权限即可。
示意图
由于用户的权限在一定时间内都是稳定不变的,所以没必要每次访问接口都去数据库查一遍,我们可以在用户登录时查一遍权限,然后缓存起来即可。
SQL
CREATE TABLE `t_permission` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`method` varchar(255) NOT NULL,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` tinyint(1) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
通用组件
public abstract class WebConstant {
/**
* 当前登录的用户信息(Session)
*/
public static final String CURRENT_USER_IN_SESSION = "current_user_in_session";
/**
* 当前登录的用户信息(ThreadLocal)
*/
public static final String USER_INFO = "user_info";
/**
* 当前用户拥有的权限
*/
public static final String USER_PERMISSIONS = "user_permissions";
}
/**
* 角色权限注解
* 注意:你首先要是一个用户才有权限,所以谈权限离不开登录,所以我在RequirePermission上加了LoginRequired
*/
@LoginRequired
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequiresPermission {
}
Controller
@RestController
@RequestMapping("/user")
public class UserController {
// 由于要查询用户权限,五张表都来了
@Autowired
private UserMapper userMapper;
@Autowired
private RoleMapper roleMapper;
@Autowired
private UserRoleMapper userRoleMapper;
@Autowired
private RolePermissionMapper rolePermissionMapper;
@Autowired
private PermissionMapper permissionMapper;
@Autowired
private HttpSession session;
@PostMapping("/login")
public Result<User> login(@RequestBody User loginInfo) {
LambdaQueryWrapper<User> lambdaQuery = Wrappers.lambdaQuery();
lambdaQuery.eq(User::getName, loginInfo.getName());
lambdaQuery.eq(User::getPassword, loginInfo.getPassword());
User user = userMapper.selectOne(lambdaQuery);
if (user == null) {
return Result.error("用户名或密码错误");
}
// 1.Session记录登录状态
session.setAttribute(WebConstant.CURRENT_USER_IN_SESSION, user);
// 2.Session缓存用户拥有的权限
session.setAttribute(WebConstant.USER_PERMISSIONS, getUserPermissions(user.getId()));
return Result.success(user);
}
private Set<String> getUserPermissions(Long uid) {
// 用户拥有的角色
LambdaQueryWrapper<UserRole> userRoleQuery = Wrappers.lambdaQuery();
userRoleQuery.eq(UserRole::getUserId, uid);
List<UserRole> userRoles = userRoleMapper.selectList(userRoleQuery);
List<Long> roleIds = ConvertUtil.resultToList(userRoles, UserRole::getRoleId);
if (CollectionUtils.isEmpty(roleIds)) {
// 没有角色,所有没有权限
return new HashSet<>();
}
// 角色拥有的权限
List<Long> permissionIds = new ArrayList<>();
rolePermissionMapper.selectBatchIds(roleIds);
roleIds.forEach(roleId -> {
LambdaQueryWrapper<RolePermission> rolePermissionQuery = Wrappers.lambdaQuery();
rolePermissionQuery.eq(RolePermission::getRoleId, roleId);
List<RolePermission> rolePermissions = rolePermissionMapper.selectList(rolePermissionQuery);
permissionIds.addAll(ConvertUtil.resultToList(rolePermissions, RolePermission::getPermissionId));
});
if (CollectionUtils.isEmpty(permissionIds)) {
// 角色都没有分配权限
return new HashSet<>();
}
// 查询权限对应的method
return Optional.ofNullable(permissionMapper.selectBatchIds(permissionIds))
.map(permissionList -> permissionList.stream().map(Permission::getMethod).collect(Collectors.toSet()))
.orElse(Collections.emptySet());
}
}
拦截器
@Component
public class SecurityInterceptor extends HandlerInterceptorAdapter {
@Autowired
private HttpSession session;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 不拦截跨域请求相关
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
// 如果类或方法上没有加@LoginRequired或@RequiredPermission(上面叠加了@LoginRequired),直接放行
if (isLoginFree(handler)) {
return true;
}
// 登录校验,session里如果没有用户信息,就抛异常给globalExceptionHandler提示“需要登录”
User user = handleLogin(request, response);
ThreadLocalUtil.put(WebConstant.USER_INFO, user);
// 权限校验,校验不通过就抛异常,交给全局异常处理
checkPermission(user, handler);
// 放行到Controller
return super.preHandle(request, response, handler);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 及时移除,避免ThreadLocal内存泄漏
ThreadLocalUtil.remove(WebConstant.USER_INFO);
super.afterCompletion(request, response, handler, ex);
}
/**
* 接口是否免登录(支持Controller上添加@LoginRequired)
*
* @param handler
* @return
*/
private boolean isLoginFree(Object handler) {
// 判断是否支持免登录
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
// 类上是否有@LoginRequired
Class<?> controllerClazz = handlerMethod.getBeanType();
LoginRequired ControllerLogin = AnnotationUtils.findAnnotation(controllerClazz, LoginRequired.class);
// 方法上是否有@LoginRequired
Method method = handlerMethod.getMethod();
LoginRequired methodLogin = AnnotationUtils.getAnnotation(method, LoginRequired.class);
return ControllerLogin == null && methodLogin == null;
}
return true;
}
/**
* 登录校验
*
* @param request
* @param response
* @return
*/
private User handleLogin(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession();
User currentUser = (User) session.getAttribute(WebConstant.CURRENT_USER_IN_SESSION);
if (currentUser == null) {
// 抛异常,请先登录
throw new BizException(ExceptionCodeEnum.NEED_LOGIN);
}
return currentUser;
}
/**
* 权限校验
*
* @param user
* @param handler
*/
private void checkPermission(User user, Object handler) {
// 如果类和当前方法上都没有加@RequiresPermission,说明不需要权限校验,直接放行
if (isPermissionFree(handler)) {
return;
}
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
Class<?> controllerClazz = handlerMethod.getBeanType();
// 代码走到这,已经很明确,当前方法需要权限才能访问,那么当前用户有没有权限呢?
@SuppressWarnings("unchecked")
Set<String> userPermissionMethods = (Set<String>) session.getAttribute(WebConstant.USER_PERMISSIONS);
String currentRequestMethod = controllerClazz.getName() + "#" + method.getName();
if (userPermissionMethods.contains(currentRequestMethod)) {
return;
}
// 当前访问的方法需要权限,但是当前用户不具备该权限
throw new BizException(ExceptionCodeEnum.PERMISSION_DENY);
}
}
/**
* 是否需要权限校验
*
* @param handler
* @return
*/
private boolean isPermissionFree(Object handler) {
// 判断是否需要权限认证
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Class<?> controllerClazz = handlerMethod.getBeanType();
Method method = handlerMethod.getMethod();
RequiresPermission controllerPermission = AnnotationUtils.getAnnotation(controllerClazz, RequiresPermission.class);
RequiresPermission methodPermission = AnnotationUtils.getAnnotation(method, RequiresPermission.class);
// 没有加@RequiresPermission,不需要权限认证
return controllerPermission == null && methodPermission == null;
}
return true;
}
}
测试
之前说过了,@RequiresPermission不再代表角色,只是说明这个接口需要权限。如何让学生也能访问这个接口?只要让学生这个角色也拥有这个权限即可。此时我们不需要去更改代码,只需要往t_role_permission这个中间表插入一条记录,让目标角色和目标方法产生关联即可。
优化
不知道大家有没有发现一个问题。
现在的设计思路是,拼接ControllerName+MethodName得到当前要访问的方法全路径,比如com.bravo.controller.UserController#needPermission,然后查询用户所有的权限,比如:
- com.bravo.controller.UserController#needPermission(包含这个接口,所以当前用户允许访问)
- com.bravo.controller.UserController#listUser
- com.bravo.controller.UserController#getById
- ...
用户的权限哪来的呢?通过t_user->t_user_role->t_role->t_role_permission->t_permission这样关联查询出来的。如果说t_permission保存了所有需要权限的方法,那么t_role_permission则记录了各个用户所拥有的权限。
t_permission里的记录哪来的?肯定我们插入的,要么通过页面手动录入,要么通过程序自动插入,总之每个@RequiresPermission标记的方法都是需要权限的,所以都要插入到t_permission。
手动插入就不提了,提供对应的add接口即可,自动插入怎么做呢?
一个较为可行的思路是:
项目启动时,得到所有Controller,然后扫描所有handler方法,过滤出带有@RequiresPermission注解的method,然后拼接methodName存入数据库。
更详细的操作是:
/**
* 系统启动时收集全部的权限方法,同步到t_permission
*/
@Component
public class PermissionMethodCollectionListener implements ApplicationListener<ContextRefreshedEvent>, ApplicationContextAware {
/**
* 这里演示通过ApplicationContextAware注入,你也可以直接使用@AutoWired
*/
private ApplicationContext applicationContext;
@Autowired
private PermissionMapper permissionMapper;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
// 得到t_permission已有的所有权限方法
Set<String> permissionsFromDB = new HashSet<>();
List<Permission> permissions = permissionMapper.selectList(new QueryWrapper<>());
if (CollectionUtils.isNotEmpty(permissions)) {
permissions.forEach(permission -> permissionsFromDB.add(permission.getMethod()));
}
// 遍历所有Controller
Map<String, Object> beanMap = applicationContext.getBeansWithAnnotation(Controller.class);
Collection<Object> beans = beanMap.values();
for (Object bean : beans) {
Class<?> controllerClazz = bean.getClass();
// 如果Controller上有@RequiresPermission,那么所有接口都要收集(isApiMethod),否则只收集打了@Permission的接口(hasPermissionAnnotation)
Predicate<Method> filter = AnnotationUtils.findAnnotation(controllerClazz, RequiresPermission.class) != null
? this::isApiMethod
: this::hasPermissionAnnotation;
// 过滤出Controller中需要权限验证的method
Set<String> permissionMethodsWithinController = getPermissionMethodsWithinController(
controllerClazz.getName(),
controllerClazz.getMethods(),
filter
);
for (String permissionMethodInMemory : permissionMethodsWithinController) {
// 如果是新增的权限方法
if (!permissionsFromDB.contains(permissionMethodInMemory)) {
Permission permission = new Permission();
permission.setModule("");
permission.setName("");
permission.setMethod(permissionMethodInMemory);
permissionMapper.insert(permission);
}
}
}
}
private Set<String> getPermissionMethodsWithinController(String controllerName, Method[] methods, Predicate<Method> filter) {
return Arrays.stream(methods)
.filter(filter)
.map(method -> {
StringBuilder sb = new StringBuilder();
String methodName = method.getName();
return sb.append(controllerName).append("#").append(methodName).toString();
})
.collect(Collectors.toSet());
}
private boolean hasPermissionAnnotation(Method method) {
return AnnotationUtils.findAnnotation(method, RequestMapping.class) != null
&& AnnotationUtils.findAnnotation(method, RequiresPermission.class) != null;
}
private boolean isApiMethod(Method method) {
return AnnotationUtils.findAnnotation(method, RequestMapping.class) != null;
}
}
反思
部分同学可能觉得权限系统设计到这个地步,已经很强了,但实际上还存在很多可以改进的地方,特别是设计思路上。大家有没有发现,这几篇文章下来,我都把接口方法(资源)和权限等同看待,认为资源就是权限,权限就是资源。所以,当我把@requiresPermission这个注解(代表权限控制)加到方法上时,大家感受不到这也是一种“耦合”。
“耦合”体现在哪呢?
举个例子,假设对于某个接口,比如listBooks(),希望由原先的“只允许校内用户查询”更改为“未登录也能查询”,你应该怎么做?
注意,是未登录,而不是游客。
“未登录”不是一种角色(角色依赖于用户),你没法把权限赋给一个不存在的角色,所以你只能改代码。我们一开始就把@RequiresPermission与listBooks()绑定了,所以这个接口一定要权限才能访问(且先不管什么角色拥有该权限),如果要“消除”这个接口的权限,只能手动去除注解,也就是改动代码。
RBAC要实现真正的动态分配权限,不仅要考虑权限的动态转移,还要能做到权限的动态消除,也就是资源和权限也要解偶。
鉴于篇幅有限,这里提供一个思路:
我们现在t_permission的数据哪来的?是不是Listener扫描所有Handler后插入的?它的选取标准是什么呢?带有@RequiresPermission注解的Controller或Handler。
但现在@RequiresPermission显然不能用了,因为直接加在方法上会耦合,后期无法为资源“去权限”。
很显然,这个时候t_permission的数据收集工作只能有我们自己做。判断标准是:
你认为哪个接口需要加权限,就自己拼好methodName,然后插入,最后手动为角色分配权限,手动为用户分配角色。
这无疑会加大我们的工作量,但“自动化”与“精细控制”本身确实存在一定的取舍。
有更好的做法欢迎留言~
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
进群,大家一起学习,一起进步,一起对抗互联网寒冬