在任意系统中,系统权限管理无疑都是尤为重要的环节。没有严格的权限管控,不用用户之间数据将相互暴露,后果不堪设想。
今天,就让我们深入探讨一下如何在系统中实现完善的权限管理
一、名词解释
在开始之前,让我们先了解一下系统鉴权中常涉及的一系列概念。
1. 数据越权
数据越权故名思意即用户权限访问了不属于自身的资源,分为下述两类:
(1) 垂直越权
垂直越权即访问高于自身级别的内容,如普通用户越权访问系统后台管理员资源。
(2) 水平越权
水平越权即访问到同级别资源内容,但内容并不属于你,如论坛系统中越权编辑或删除他人发布的贴子。
2. 鉴权模型
针对上述提到的两类越权场景,实现鉴权的方式也有多类,在设计上通常集成使用。
(1) RBAC模型
RBAC
即基于角色访问控制 (Role-Based Access Control)
,将系统资源绑定于不同的角色,再将用户与角色之间相关,通过校验用户的角色判断是否拥有权限。
在系统的功能设计中,通常在菜单权限设计中利用 RBAC
模型,从而实现功能间的隔离。
(2) ABAC模型
ABAC
则为基于属性的访问控制 (Attribute-Based Access Control)
,即用户直接与资源属性进行绑定。
相对于 RBAC
而言虽然 ABAC
能够实现更精确的权限管理,但缺点也显而易见,产生的关联数据相对更多且实现更为复杂。因此,在核心数据中通常才基于 ABAC
模型设计。
(3) ReBAC模型
ReBAC
为基于关系的访问控制 (Relationship-Based Access Control)
,与 RBAC
有一定的类似。
与 RBAC
所不同的是在 ReBAC
中各级角色间存在关联。最为常见的即部门关系,同个部门下可共享权限,由此赋予部门负责人相应权限即可,部门成员即可继承权限,如下述图例:
如此设计的好处即无需像 RBAC
中单独为每个角色都绑定权限,极大节省了大量重复关联数据从而节省资源。
二、功能权限
在系统中主要包含两部分权限管理,功能菜单以及数据内容权限,让我们先以菜单权限入手。
1. 架构设计
在菜单权限中设计中,常更倾向于 RBAC
由角色实现更优的管理。
不同的角色拥有不同的菜单权限,而每个用户又拥有不同的角色,通过角色将用户与菜单关联。
如此设计的好处在于可实现少量数据关联大量资源,若直接将用户与菜单执行关联,随着用户数量的增加将产生大量的重复数据,造成不必要的资源浪费。
2. 用户认证
确定模型结构后,便可开始具体的代码实现设计。
最基础的当然莫属于用户登录认证了,在登录认证中常采用双认证机制。即权限认证与过期认证相结合,利用 Spring Security
实现用户账号认证,而 JWT
则用于实现过期登录认证。
关于具体的双认证实现细节这里不再展开,在之前的文章中已经详细分享过,感兴趣的话可去看一下:Spring Security权限认证实战。
这里仅提一点,即登录通过之后将用户登录信息存入请求上下文中供后续使用。
public static void setAuthentication(UserDTO user) {
Authentication authentic = new UsernamePasswordAuthenticationToken(user, null);
SecurityContextHolder.getContext().setAuthentication(authentic);
}
在 Spring
中可继承 OncePerRequestFilter
过滤器实现对请求的认证处理,其增强实现了 Filter
过滤器,保证在转发请求时每个请求也仍只会处理一次。
在 doFilterInternal()
读取请求认证头获取登录信息,当认证通过时存入 Spring Security
上下文环境中。
@Component
@RequiredArgsConstructor
public class TokenFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("Token");
if (StringUtils.isBlank(token)) {
this.processNotPermit("Please login first.", response);
return;
}
UserDTO user;
try {
// 校验 JWT
Claims claims = jwtUtils.parse(token);
String content = claims.get("sub").toString();
user = objectMapper.readValue(content, UserDTO.class);
} catch (Exception e) {
this.processNotPermit("Login fail.", response);
return;
}
if (Objects.isNull(user)) {
this.processNotPermit("Invalid token.", response);
}
// 校验通过
SecurityManager.setAuthentication(user);
filterChain.doFilter(request, response);
}
private void processNotPermit(String msg, HttpServletResponse response) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(203);
response.getWriter().write(objectMapper.writeValueAsString(ResultData.reject(msg)));
}
}
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
兼容 IOC
容器以 bean
形式调用,其配置格式为:@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
则表示权限不足,则不会继续执行方法体内容而会抛出 AccessDeniedException
异常,可通过 @ExceptionHandler
注解在全局异常中实现管理。
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 监听异常请求并处理返回
*/
@SuppressWarnings("rawtypes")
@ExceptionHandler(Exception.class)
public ResultData handleNotFoundException(Exception ex) {
if (ex instanceof AccessDeniedException) {
return ResultData.denies(ex.getMessage());
}
return ResultData.failed(ex.getMessage());
}
}
三、注解详解
在上述中初步了解了 @PreAuthorize
使用方式,下面让我们进一步了解注解的使用。
Spring Security
中针对校验提供了多个注解,其中较常用的有下述两个:
注解 | 描述 |
---|---|
@PreAuthorize | 在接口前置执行校验。 |
@PostAuthorize | 在接口后置执行校验。 |
而除了之前提到的 @bean.method(param)
形式调用外,其支持多样灵活的表达式兼容,下面则分别进行介绍。
1. 对象引用
其中最基础的方式即通过 #
引用获取当前接口入参内容,如下述即校验入参 storeId
值是否为 123
。
@PreAuthorize("#storeId == '123'")
public Store query(String storeId) {
}
当接口入参为对象时,#
表达式也支持 obj.field
的形式引用特定字段。
如下述中接口入参为 Store
类,则可以通过 #store.storeId
单独校验 storeId
属性。
public class Store {
private Long storeId;
}
@PreAuthorize("#store.storeId == '2'")
public Store query(Store store) {
}
2. 参数别名
Spring Security
同时提供了 @P
实现别名配置,当声明的参数名称过长时,可实现代码简化。
如下述中方法入参字段为 aVeryLongParameterNameOfStore
,而通过 @P("s")
即可实现参数重命名,在表达式中便可通过 #s.storeId
实现数据引用。
@PreAuthorize("#s.storeId == '2'")
public Store query(@P("s") Store aVeryLongParameterNameOfStore) {
}
3. 方法调用
在之前的功能鉴权中也看到了可通过 @
实现方法的调用。
如声明了 bean
实例 ts
,且包含方法 isOk()
,内容如下:
@Component("ts")
public class TestServioce {
public boolean isOk() {
return true;
}
}
在表达式中则可通过 @bean.method(param)
格式调用,如下即通过 @ts.isOk()
调用上述方法。
@PreAuthorize("@ts.isOk()")
public Store query(Store store) {
}
4. 组合校验
在表达式中也支持 and
和 or
关键字实现条件的组合内容校验。
如下即通过 and
实现 storeId=1
且 name=AA
的复合校验。
@PreAuthorize("#s.storeId == '1' and #s.name == 'AA' ")
public Store query(Store s) {
}
四、数据权限
了解上述基本内容后,那么接下来让我们来看一下如何实现数据权限管理。
1. 模型设计
在数据鉴权中,显然 RBAC
角色模型不再适用,权限粒度不够将导致水平越权情况发生。
因此,在数据鉴权中,更多的是采用 ABAC
模型,将用户与数据直接进行关联,实现最小粒度控制。
换言之,即针对接口输入的内容比对是否拥有对应的资源权限。以商城系统为例,系统内存在多个店铺,每个用户拥有不同的店铺,用户与店铺之间则通过关联表直接关联。
2. 工厂设计
在代码设计上,为了适配同样的数据权限管理,这边定义了权限管理接口。
public interface PermitHandler {
String name();
boolean hasPermit(Object arg);
}
下面以店铺权限校验为例,让我们编写对应的校验逻辑。
在实现上与刚才提到 @PreAuthorize
类似,读取 Security
上下文得到用户后查询用户拥有的店铺权限,并与输入数据进行比对。
@Component("storePermitHandler")
@RequiredArgsConstructor
public class StorePermitHandler implements PermitHandler {
private final StoreCache storeCache;
@Override
public String name() {
return "store";
}
@Override
public boolean hasPermit(Object arg) {
if (Objects.isNull(arg)) {
return false;
}
Long userId = SecurityManager.getUserId();
Set<Long> storeIds = storeCache.readByUser(userId);
return storeIds.contains(Long.valueOf(arg.toString()));
}
}
3. 接口设计
完成上述声明后,便可通过 @PreAuthorize
定义具体的接口权限。
如下述即定义需拥有 store.query
接口查询权限,同时查询的 storeId
需属于用户。
@GetMapping("{storeId}")
@PreAuthorize("@pm.hasPermit('store.query') and @storePermitHandler.hasPermit(#storeId)")
public ResultData<Store> query(@PathVariable String storeId) {
Store store = storeService.lambdaQuery()
.eq(Store::getStoreId, storeId)
.one();
return ResultData.success(store);
}
五、手动管理
在上述的示例中基于 Spring Security
自带注解及表达式可实现数据权限校验。
但在复杂的业务情况下,表达式复杂度将快速膨胀且无法灵活定义,因此自定义注解则更为合适,下面就具体分享如何设计实现。
1. 权限注解
通过自定义权限注解,当方法参数标识了注解时执行相应的鉴权逻辑校验。
同样以刚才提到的用户店铺权限为例,声明注解 @StorePermit
作用于字段即方法,内容如下:
@Target({
ElementType.FIELD,
ElementType.PARAMETER
})
@Retention(RetentionPolicy.RUNTIME)
public @interface StorePermit {
}
同样的,在之前的工厂实例基础上新增方法 hasPermit(Annotation annotation, Object arg)
实现校验逻辑,只有当注解存在且为 @StorePermit
时才触发校验。
@Component
@RequiredArgsConstructor
public class StorePermitHandler implements PermitHandler {
@Override
public boolean hasPermit(Annotation annotation, Object arg) {
if (Objects.isNull(annotation) || annotation.annotationType() != StorePermit.class) {
return true;
}
return hasPermit(arg);
}
}
2. 切面实现
注解声明与校验逻辑编写完成之后,便可编写对应的切面实现。
在切面中通过环切遍历接口入参,若声明的接口入参标识的鉴权注解,则执行上述定义的鉴权逻辑。当鉴权不通过时,则返回 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 hasPermit = handler.lackPermit(annotation, arg);
if (!hasPermit) {
// 权限不足
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;
}
}
3. 测试接口
完成这一切准备工作之后,让我们以店铺查询接口为例。
定义店铺查询接口,在接口入参声明 @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);
}
}
通过上述的示例可以看到,通过注解与切面结合的方式,在实现数据鉴权的同时简化鉴权逻辑,业务代码无需再关注相应的权限问题,极大简化了代码复杂度。
六、后置鉴权
1. 基本介绍
在上述场景中,不论是 @PreAuthorize
或自定义切面都针对前置校验,即根据输入执行对应的权限校验。
这类方案所依赖的前提即需要能通过输入内容直接或间接关联数据,如通过 storeId
进行精准查询。但当模糊搜索时此方案将失效,如输入条件为时间的范围搜索,此时无法通过输入直接过滤权限。
2. 方案设计
针对此类场景,则需要采取后置权限校验,即针对结果进行鉴权管理,返回相应提示或剔除无权限数据。
在 Spring Security
同样提供了 @PostAuthorize
基于后置校验,通过 returnObject
引用结果,若返回 false
则会返回 403
无权限。
@PostAuthorize("returnObject.storeId != null")
public Store query(String storeId) {
return new Store();
}
采用 @PostAuthorize
存在弊端即模糊搜索时其只能实现全量放行或全量拦截,即当返回集合包含多条记录时无法实现仅返回有权限部分。
因此,对于部分剔除的场景,可以采用上述自定义切面方式处理,在切面中基于反射剔除无权限记录,实现逻辑类似此处不再赘述。
参考链接
- 仓库地址:system-authority