系统鉴权设计详解


在任意系统中,系统权限管理无疑都是尤为重要的环节。没有严格的权限管控,不用用户之间数据将相互暴露,后果不堪设想。

今天,就让我们深入探讨一下如何在系统中实现完善的权限管理

一、名词解释

在开始之前,让我们先了解一下系统鉴权中常涉及的一系列概念。

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);
    }
}

通过上述的示例可以看到,通过注解与切面结合的方式,在实现数据鉴权的同时简化鉴权逻辑,业务代码无需再关注相应的权限问题,极大简化了代码复杂度。


参考链接

  1. 仓库地址:system-authority

文章作者: 烽火戏诸诸诸侯
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 烽火戏诸诸诸侯 !
  目录