在任意系统中,系统权限管理无疑都是尤为重要的环节。没有严格的权限管控,不用用户之间数据将相互暴露,后果不堪设想。
今天,就让我们深入探讨一下如何在系统中实现完善的权限管理
一、名词解释
在开始之前,让我们先了解一下系统鉴权中常涉及的一系列概念。
1. 数据越权
数据越权故名思意即用户权限访问了不属于自身的资源,分为下述两类:
(1) 垂直越权
垂直越权即访问高于自身级别的内容,如普通用户越权访问系统后台管理员资源。
(2) 水平越权
水平越权即访问到同级别资源内容,但内容并不属于你,如论坛系统中越权编辑或删除他人发布的贴子。
2. 鉴权模型
针对上述提到的两类越权场景,实现鉴权的方式也有多类,在设计上通常集成使用。
(1) RBAC模型
RBAC
即基于角色访问控制 (Role-Based Access Control)
,将系统资源绑定于不同的角色,再将用户与角色之间相关,通过校验用户的角色判断是否拥有权限。
在系统的功能设计中,通常在菜单权限设计中利用 RBAC
模型,从而实现功能间的隔离。
(2) ABAC模型
ABAC
则为基于属性的访问控制 (Attribute-Based Access Control)
,即用户直接与资源属性进行绑定。
相对于 RBAC
而言虽然 ABAC
能够实现更精确的权限管理,但缺点也显而易见,产生的关联数据相对更多且实现更为复杂。因此,在核心数据中通常才基于 ABAC
模型设计。
二、功能权限
在系统中主要包含两部分权限管理,功能菜单以及数据内容权限,让我们先以菜单权限入手。
1. 架构设计
在菜单权限中设计中,常更倾向于 RBAC
由角色实现更优的管理。
不同的角色拥有不同的菜单权限,而每个用户又拥有不同的角色,通过角色将用户与菜单关联。
如此设计的好处在于可实现少量数据关联大量资源,若直接将用户与菜单执行关联,随着用户数量的增加将产生大量的重复数据,造成不必要的资源浪费。
2. 用户认证
确定模型结构后,便可开始具体的代码实现设计。
最基础的当然莫属于用户登录认证了,在登录认证中常采用双认证机制。即权限认证与过期认证相结合,利用 Spring Security
实现用户账号认证,而 JWT
则用于实现过期登录认证。
关于具体的双认证实现细节这里不再展开,在之前的文章中已经详细分享过,感兴趣的话可去看一下:Spring Security权限认证实战。
这里仅提一点,即登录通过之后将用户登录信息存入请求上下文中供后续使用。
/**
* 设置认证上下文信息
*
* @param user 登录用户
*/
public static void setAuthentication(UserDTO user) {
Authentication authentic = new UsernamePasswordAuthenticationToken(user, null);
SecurityContextHolder.getContext().setAuthentication(authentic);
}
3. 权限管理
根据上述设计图中的结构定义相应的用户、角色以及菜单关联内容后,便可开始具体的实现。
这里略去具体的业务层逻辑代码,仅演示如果通过 Security
实现管理。在这里利用 Spring Security
中 @PreAuthorize
注解的特性,其会接口执行前触发,我们便可前置校验用户是否满足权限。
按照提到的逻辑,让我们先定义权限校验的实现业务。逻辑上并不复杂,即读取请求上下文得到用户信息后查询对应的菜单权限,再与接口权限码相匹配是否包含。
完整的代码实现如下:
@Component("pm")
@RequiredArgsConstructor
public class PermitManager {
private static final String SEPARATOR = ",";
private static final String ALL_PERMIT = "*.*";
private final UserRoleService userRoleService;
/**
* 登录用户是否包含指定权限
*
* @param permit 菜单权限
*/
public boolean hasPermit(String permit) {
Long userId = getUserId();
if (Objects.isNull(userId)) {
return false;
}
Set<String> menuPermits = userRoleService.getUserMenus(userId);
if (menuPermits.contains(ALL_PERMIT)) {
// 拥有所有菜单权限
return true;
}
return menuPermits.contains(permit);
}
private Long getUserId() {
Long userId = null;
try {
UserDTO user = (UserDTO) SecurityContextHolder.getContext()
.getAuthentication()
.getPrincipal();
userId = user.getUserId();
} catch (Exception e) {
log.error("Not found user, message: {}", e.getMessage());
}
return userId;
}
}
4. 接口设计
完成上述工作之后便可在接口服务上绑定菜单权限。
其中 @PreAuthorize
注解的声明格式为 @bean.method(param)
。如下述示例中即给订单查询接口绑定菜单权限 order.query
,当请求接口时便会执行上述 PermitManager
中定义的校验逻辑。
@RestController
@RequestMapping("/api/order")
@RequiredArgsConstructor
public class OrderResource {
private final OrderService orderService;
@GetMapping("get")
@PreAuthorize("@pm.hasPermit('order.query')")
public ResponseEntity<Order> query(String orderId) {
Order order = orderService.lambdaQuery()
.eq(Order::getOrderId, orderId)
.one();
return ResponseEntity.ok(order);
}
}
若执行 @PreAuthorize
注解校验逻辑返回 false
未通过,则不会继续执行方法体内容而会直接详情请求返回 403
无权限。
三、数据权限
在上述的介绍中,我们实现了菜单的权限管理,下面让我们来看一下如何实现数据权限管理。
1. 模型设计
在数据鉴权中,显然 RBAC
角色模型不再适用,权限粒度不够将导致水平越权情况发生。
因此,在数据鉴权中,更多的是采用 ABAC
模型,将用户与数据直接进行关联,实现最小粒度控制。
以商城系统为例,系统内存在多个店铺,每个用户拥有不同的店铺,用户与店铺之间则通过关联表直接关联。
2. 权限注解
针对 ABAC
模型在代码设计中更推荐的方式是基于注解与切面的方式,从而将通用鉴权逻辑剥离于业务之外。
通过自定义权限注解,当方法参数标识了注解时执行相应的鉴权逻辑校验。
同样以刚才提到的用户店铺权限为例,声明注解 @StorePermit
作用于字段即方法,内容如下:
@Target({
ElementType.FIELD,
ElementType.PARAMETER
})
@Retention(RetentionPolicy.RUNTIME)
public @interface StorePermit {
}
在代码设计上,为了适配同样的数据权限管理,这边定义了权限管理接口。
public interface PermitHandler {
String name();
boolean lackPermit(Annotation annotation, Object arg);
}
下面以店铺权限校验为例,让我们编写对应的校验逻辑。
在实现上与刚才提到 @PreAuthorize
类似,读取 Security 上下文得到用户后查询用户拥有的店铺权限,并与输入数据进行比对。
完成的实现代码如下:
@Component
@RequiredArgsConstructor
public class StorePermitHandler implements PermitHandler {
private final StoreCache storeCache;
@Override
public String name() {
return "store";
}
@Override
public boolean lackPermit(Annotation annotation, Object arg) {
if (Objects.isNull(annotation) || annotation.annotationType() != StorePermit.class) {
return false;
}
if (Objects.isNull(arg)) {
return true;
}
Long userId = SecurityManager.getUserId();
Set<Long> storeIds = storeCache.readByUser(userId);
return !storeIds.contains(Long.valueOf(arg.toString()));
}
}
3. 切面实现
注解声明与校验逻辑编写完成之后,便可编写对应的切面实现。
在切面中通过环切遍历接口入参,若声明的接口入参标识的鉴权注解,则执行上述定义的鉴权逻辑。当鉴权不通过时,则返回 403
权限不足。
同样的,完整的切面实现代码如下:
@Aspect
@Component
@RequiredArgsConstructor
public class PermitAspect {
private final Map<String, PermitHandler> permitHandlerMap;
@Pointcut("execution (public * xyz.ibudai.authority.rest.*.*(..))")
public void pointcut() {
}
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
Method method = getMethod(joinPoint);
if (Objects.isNull(method)) {
return joinPoint.proceed();
}
Object[] args = joinPoint.getArgs();
Annotation[][] annotations = method.getParameterAnnotations();
for (int i = 0; i < annotations.length; i++) {
Object arg = args[i];
for (Annotation annotation : annotations[i]) {
for (Map.Entry<String, PermitHandler> entry : permitHandlerMap.entrySet()) {
PermitHandler handler = entry.getValue();
boolean lackPermit = handler.lackPermit(annotation, arg);
if (lackPermit) {
return ResultData.denies(String.format("Lack %s permission of %s", handler.name(), arg));
}
}
}
}
// 校验合法,放行
return joinPoint.proceed();
}
private Method getMethod(ProceedingJoinPoint joinPoint) {
Signature signature = joinPoint.getSignature();
if (!(signature instanceof MethodSignature methodSignature)) {
return null;
}
// 如果是代理对象,取真实方法
Method method = methodSignature.getMethod();
if (method.getDeclaringClass().isInterface()) {
try {
method = joinPoint.getTarget()
.getClass()
.getDeclaredMethod(method.getName(), method.getParameterTypes());
} catch (NoSuchMethodException e) {
return null;
}
}
return method;
}
}
4. 测试接口
完成这一切准备工作之后,让我们以店铺查询接口为例。
定义店铺查询接口,在接口入参声明 @StorePermit
表示接口启用店铺鉴权检验。当请求接口时则会将输入的店铺与用户拥有的店铺比对,比对不通过则不会执行具体的方法业务。
@RestController
@RequestMapping("/api/store")
@RequiredArgsConstructor
public class StoreResource {
private final StoreService storeService;
@GetMapping("/get")
public ResultData<Store> query(@StorePermit String storeId) {
Store store = storeService.lambdaQuery()
.eq(Store::getStoreId, storeId)
.one();
return ResultData.success(store);
}
}
通过上述的示例可以看到,通过注解与切面结合的方式,在实现数据鉴权的同时简化鉴权逻辑,业务代码无需再关注相应的权限问题,极大简化了代码复杂度。
参考链接
- 仓库地址:system-authority