欢迎光临
我们一直在努力

实战:手写一个通用Web层鉴权注解,解决水平权限漏洞

实战:手写一个通用Web层鉴权注解,解决水平权限漏洞

    • 一、背景:一次渗透测试引发的改造
    • 二、需求分析:如何高效修复
    • 三、业务模型:用户-公司授权关系
    • 四、整体架构设计
    • 五、代码实现:一步一步来
      • 5.1 注解定义
      • 5.2 权限管理服务
      • 5.3 AOP切面:核心逻辑
    • 六、使用示例
      • 6.1 场景1:最简单的用法
      • 6.2 场景2:对象属性
      • 6.3 场景3:批量操作
      • 6.4 场景4:嵌套属性
      • 6.5 场景5:类级别默认配置
    • 七、遇到的坑和解决方案
      • 坑1:参数名获取不到
      • 坑2:循环依赖
      • 坑3:集合参数去重
      • 坑4:事务失效
    • 八、编译期校验:把问题扼杀在摇篮里
      • 8.1 创建注解处理器
      • 8.2 注册处理器
    • 九、性能优化建议
      • 9.1 缓存权限结果
      • 9.2 批量接口合并请求
      • 9.3 反射优化
    • 十、总结与展望

🌺The Begin🌺点点关注,收藏不迷路🌺

一、背景:一次渗透测试引发的改造

前段时间公司做渗透测试,我们系统暴露了一个典型的安全漏洞——水平权限漏洞。

简单来说,就是用户A可以看到不属于他所在公司的数据。比如:用户A登录系统后,修改URL中的公司ID参数,就能查看到B公司的业务数据。

这个问题在行业内其实很常见,核心原因是Web层缺少水平鉴权。我们的系统运行好几年了,接口越来越多,但鉴权这块一直没好好做。

漏洞等级被定为高危,修复工作立刻提上议程。

二、需求分析:如何高效修复

面对几十个Controller、几百个接口,我的修复方案必须满足:

  • 接入简单:开发人员加个注解就能搞定,不用写重复代码
  • 灵活通用:能处理各种奇葩的入参结构(直接参数、对象属性、集合嵌套等)
  • 兼容老代码:不能影响现有逻辑,老的接口不加注解就保持原样
  • 可扩展:后续可能增加角色鉴权、垂直鉴权等
  • 三、业务模型:用户-公司授权关系

    先看下我们的权限模型:

    #mermaid-svg-pvSCGENvhGWxM8ac{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pvSCGENvhGWxM8ac .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pvSCGENvhGWxM8ac .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pvSCGENvhGWxM8ac .error-icon{fill:#552222;}#mermaid-svg-pvSCGENvhGWxM8ac .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pvSCGENvhGWxM8ac .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pvSCGENvhGWxM8ac .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pvSCGENvhGWxM8ac .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pvSCGENvhGWxM8ac .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pvSCGENvhGWxM8ac .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pvSCGENvhGWxM8ac .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pvSCGENvhGWxM8ac .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pvSCGENvhGWxM8ac .marker.cross{stroke:#333333;}#mermaid-svg-pvSCGENvhGWxM8ac svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pvSCGENvhGWxM8ac p{margin:0;}#mermaid-svg-pvSCGENvhGWxM8ac .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-pvSCGENvhGWxM8ac .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-pvSCGENvhGWxM8ac .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-pvSCGENvhGWxM8ac .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-pvSCGENvhGWxM8ac .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-pvSCGENvhGWxM8ac .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-pvSCGENvhGWxM8ac .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-pvSCGENvhGWxM8ac .node rect,#mermaid-svg-pvSCGENvhGWxM8ac .node circle,#mermaid-svg-pvSCGENvhGWxM8ac .node ellipse,#mermaid-svg-pvSCGENvhGWxM8ac .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pvSCGENvhGWxM8ac .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-pvSCGENvhGWxM8ac .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-pvSCGENvhGWxM8ac :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    has

    has

    User

    string

    userName

    PK

    string

    nickName

    UserCompany

    string

    userName

    FK

    long

    companyId

    FK

    Company

    long

    companyId

    PK

    string

    companyName

    规则很简单:

    • 一个用户可以被授权访问多个公司的数据
    • 一个公司可以有多个授权用户
    • 用户只能查看他有权限的公司的数据

    四、整体架构设计

    鉴权注解的核心流程:

    #mermaid-svg-ntBCJV3PhSpPkO2S{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ntBCJV3PhSpPkO2S .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ntBCJV3PhSpPkO2S .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ntBCJV3PhSpPkO2S .error-icon{fill:#552222;}#mermaid-svg-ntBCJV3PhSpPkO2S .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ntBCJV3PhSpPkO2S .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ntBCJV3PhSpPkO2S .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ntBCJV3PhSpPkO2S .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ntBCJV3PhSpPkO2S .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ntBCJV3PhSpPkO2S .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ntBCJV3PhSpPkO2S .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ntBCJV3PhSpPkO2S .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ntBCJV3PhSpPkO2S .marker.cross{stroke:#333333;}#mermaid-svg-ntBCJV3PhSpPkO2S svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ntBCJV3PhSpPkO2S p{margin:0;}#mermaid-svg-ntBCJV3PhSpPkO2S .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-ntBCJV3PhSpPkO2S .cluster-label text{fill:#333;}#mermaid-svg-ntBCJV3PhSpPkO2S .cluster-label span{color:#333;}#mermaid-svg-ntBCJV3PhSpPkO2S .cluster-label span p{background-color:transparent;}#mermaid-svg-ntBCJV3PhSpPkO2S .label text,#mermaid-svg-ntBCJV3PhSpPkO2S span{fill:#333;color:#333;}#mermaid-svg-ntBCJV3PhSpPkO2S .node rect,#mermaid-svg-ntBCJV3PhSpPkO2S .node circle,#mermaid-svg-ntBCJV3PhSpPkO2S .node ellipse,#mermaid-svg-ntBCJV3PhSpPkO2S .node polygon,#mermaid-svg-ntBCJV3PhSpPkO2S .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ntBCJV3PhSpPkO2S .rough-node .label text,#mermaid-svg-ntBCJV3PhSpPkO2S .node .label text,#mermaid-svg-ntBCJV3PhSpPkO2S .image-shape .label,#mermaid-svg-ntBCJV3PhSpPkO2S .icon-shape .label{text-anchor:middle;}#mermaid-svg-ntBCJV3PhSpPkO2S .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ntBCJV3PhSpPkO2S .rough-node .label,#mermaid-svg-ntBCJV3PhSpPkO2S .node .label,#mermaid-svg-ntBCJV3PhSpPkO2S .image-shape .label,#mermaid-svg-ntBCJV3PhSpPkO2S .icon-shape .label{text-align:center;}#mermaid-svg-ntBCJV3PhSpPkO2S .node.clickable{cursor:pointer;}#mermaid-svg-ntBCJV3PhSpPkO2S .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ntBCJV3PhSpPkO2S .arrowheadPath{fill:#333333;}#mermaid-svg-ntBCJV3PhSpPkO2S .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ntBCJV3PhSpPkO2S .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ntBCJV3PhSpPkO2S .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ntBCJV3PhSpPkO2S .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ntBCJV3PhSpPkO2S .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ntBCJV3PhSpPkO2S .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ntBCJV3PhSpPkO2S .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ntBCJV3PhSpPkO2S .cluster text{fill:#333;}#mermaid-svg-ntBCJV3PhSpPkO2S .cluster span{color:#333;}#mermaid-svg-ntBCJV3PhSpPkO2S div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ntBCJV3PhSpPkO2S .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ntBCJV3PhSpPkO2S rect.text{fill:none;stroke-width:0;}#mermaid-svg-ntBCJV3PhSpPkO2S .icon-shape,#mermaid-svg-ntBCJV3PhSpPkO2S .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ntBCJV3PhSpPkO2S .icon-shape p,#mermaid-svg-ntBCJV3PhSpPkO2S .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ntBCJV3PhSpPkO2S .icon-shape rect,#mermaid-svg-ntBCJV3PhSpPkO2S .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ntBCJV3PhSpPkO2S .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ntBCJV3PhSpPkO2S .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ntBCJV3PhSpPkO2S :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    权限平台

    请求处理流程

    HTTP请求

    Spring MVC

    是否有@UserPermission?

    直接执行业务方法

    进入AOP切面

    从Request获取用户信息

    是否是Admin?

    从入参提取鉴权对象

    调用权限平台接口

    是否有权限?

    抛出权限异常

    用户-公司关系服务

    五、代码实现:一步一步来

    5.1 注解定义

    首先定义注解,通过属性来描述"要从哪里取鉴权信息":

    package com.example.auth.annotation;

    import java.lang.annotation.*;

    /**
    * 用户权限注解
    * 用在Controller方法或类上,进行水平权限校验
    */

    @Target({ElementType.TYPE, ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface UserPermission {

    /**
    * 鉴权对象类型:单个公司还是多个公司
    */

    AuthObjectType objectType() default AuthObjectType.COMPANY;

    /**
    * 鉴权值类型:描述如何从入参中提取值
    */

    AuthValueType valueType() default AuthValueType.RAW;

    /**
    * 参数索引,当valueType不是RAW时,指定从第几个参数取值
    */

    int index() default 0;

    /**
    * 参数名称,支持多级,如 "companyInfo.companyId"
    */

    String paramName() default "companyId";

    /**
    * 是否忽略鉴权,用于覆盖类上的注解
    */

    boolean ignore() default false;
    }

    两个枚举的定义:

    package com.example.auth.annotation;

    /**
    * 鉴权对象类型
    */

    public enum AuthObjectType {
    COMPANY, // 单个公司
    COMPANIES // 多个公司
    }

    package com.example.auth.annotation;

    /**
    * 鉴权值类型:定义如何从入参中提取值
    */

    public enum AuthValueType {

    RAW, // 原始参数,直接就是companyId或companyIds

    OBJECT_FIELD, // 对象的属性,如 bo.companyId

    COLLECTION_FIELD, // 集合元素的属性,如 List<Bo> 取 Bo.companyId

    NESTED_FIELD, // 嵌套属性,如 bo.companyInfo.companyId

    COLLECTION_NESTED // 集合中的嵌套属性,如 bo.companyList.companyId
    }

    5.2 权限管理服务

    封装调用外部权限平台的逻辑:

    package com.example.auth.manager;

    import com.example.auth.client.UserPermissionFeignClient;
    import com.example.common.exception.BizException;
    import com.example.common.util.UserUtil;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.apache.commons.collections4.CollectionUtils;
    import org.apache.commons.lang3.BooleanUtils;
    import org.springframework.stereotype.Component;

    import java.util.List;
    import java.util.stream.Collectors;

    /**
    * 用户权限管理器
    * 封装调用权限平台的逻辑
    */

    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class UserPermissionManager {

    private final UserPermissionFeignClient permissionClient;

    /**
    * 校验用户是否有指定公司的权限
    */

    public boolean checkCompany(String userName, Long companyId) {
    if (companyId == null) {
    throw new BizException("公司ID不能为空");
    }
    log.debug("校验用户{}对公司{}的权限", userName, companyId);

    var result = permissionClient.checkCompany(userName, companyId);
    return checkResult(result);
    }

    /**
    * 校验用户是否有所有指定公司的权限
    */

    public boolean checkCompanies(String userName, List<Long> companyIds) {
    if (CollectionUtils.isEmpty(companyIds)) {
    throw new BizException("公司ID列表不能为空");
    }

    // 先去重,减少调用次数
    List<Long> distinctIds = companyIds.stream()
    .distinct()
    .collect(Collectors.toList());

    log.debug("校验用户{}对{}个公司的权限", userName, distinctIds.size());

    if (distinctIds.size() == 1) {
    // 单个公司走单条接口
    return checkCompany(userName, distinctIds.get(0));
    }

    var result = permissionClient.checkCompanies(userName, distinctIds);
    return checkResult(result);
    }

    private boolean checkResult(Result<Boolean> result) {
    if (result == null || !result.isSuccess() || result.getData() == null) {
    log.error("调用权限平台失败: {}", result);
    throw new BizException("权限校验服务异常");
    }
    return BooleanUtils.isTrue(result.getData());
    }
    }

    5.3 AOP切面:核心逻辑

    这是最关键的代码,负责拦截请求、提取鉴权值、调用权限服务:

    package com.example.auth.aspect;

    import com.example.auth.annotation.UserPermission;
    import com.example.auth.annotation.AuthObjectType;
    import com.example.auth.annotation.AuthValueType;
    import com.example.auth.manager.UserPermissionManager;
    import com.example.auth.model.UserInfo;
    import com.example.common.exception.BizException;
    import com.example.common.util.UserUtil;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.aspectj.lang.ProceedingJoinPoint;
    import org.aspectj.lang.annotation.Around;
    import org.aspectj.lang.annotation.Aspect;
    import org.aspectj.lang.annotation.Pointcut;
    import org.aspectj.lang.reflect.MethodSignature;
    import org.springframework.stereotype.Component;
    import org.springframework.web.context.request.RequestContextHolder;
    import org.springframework.web.context.request.ServletRequestAttributes;

    import java.beans.PropertyDescriptor;
    import java.lang.reflect.Method;
    import java.util.Collection;
    import java.util.List;
    import java.util.stream.Collectors;

    /**
    * 用户权限切面
    */

    @Slf4j
    @Aspect
    @Component
    @RequiredArgsConstructor
    public class UserPermissionAspect {

    private final UserPermissionManager permissionManager;

    /**
    * 切点:所有Controller下的public方法
    */

    @Pointcut("execution(public * com.example.web.controller..*.*(..))")
    public void controllerMethod() {}

    @Around("controllerMethod()")
    public Object checkPermission(ProceedingJoinPoint joinPoint) throws Throwable {
    // 1. 获取注解
    UserPermission annotation = getAnnotation(joinPoint);
    if (annotation == null || annotation.ignore()) {
    // 没注解或忽略鉴权,直接放行
    return joinPoint.proceed();
    }

    // 2. 获取当前用户
    UserInfo currentUser = getCurrentUser();
    if (currentUser == null) {
    throw new BizException("获取用户信息失败");
    }

    // 3. Admin直接放行
    if (UserUtil.isAdmin(currentUser.getUserName())) {
    log.debug("Admin用户放行");
    return joinPoint.proceed();
    }

    // 4. 从入参中提取鉴权值
    Object authValue = extractAuthValue(joinPoint, annotation);

    // 5. 校验权限
    boolean hasPermission = checkUserPermission(
    currentUser.getUserName(),
    authValue,
    annotation.objectType()
    );

    if (!hasPermission) {
    log.warn("用户{}没有权限访问: {}", currentUser.getUserName(), authValue);
    throw new BizException("您没有权限访问该数据");
    }

    // 6. 放行
    return joinPoint.proceed();
    }

    /**
    * 获取方法上的注解,优先取方法级,没有则取类级
    */

    private UserPermission getAnnotation(ProceedingJoinPoint joinPoint) {
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    Class<?> targetClass = signature.getDeclaringType();

    // 方法上的注解优先级更高
    UserPermission methodAnn = method.getAnnotation(UserPermission.class);
    if (methodAnn != null) {
    return methodAnn;
    }

    return targetClass.getAnnotation(UserPermission.class);
    }

    /**
    * 从请求中获取当前用户
    */

    private UserInfo getCurrentUser() {
    ServletRequestAttributes attrs = (ServletRequestAttributes)
    RequestContextHolder.getRequestAttributes();
    if (attrs == null) {
    return null;
    }
    return (UserInfo) attrs.getRequest().getAttribute("userInfo");
    }

    /**
    * 从方法参数中提取鉴权值(核心方法)
    */

    private Object extractAuthValue(ProceedingJoinPoint joinPoint, UserPermission annotation) {
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    String[] paramNames = signature.getParameterNames();
    Object[] args = joinPoint.getArgs();

    if (paramNames == null || paramNames.length == 0) {
    throw new BizException("方法没有参数,无法提取鉴权值");
    }

    AuthValueType valueType = annotation.valueType();

    // 场景1:原始参数
    if (valueType == AuthValueType.RAW) {
    return extractRawParam(paramNames, args, annotation.paramName());
    }

    // 其他场景:需要从对象中取值
    int index = annotation.index();
    if (index < 0 || index >= args.length) {
    throw new BizException("参数索引越界: " + index);
    }

    Object target = args[index];
    if (target == null) {
    throw new BizException("第" + index + "个参数为null");
    }

    switch (valueType) {
    case OBJECT_FIELD:
    // 场景2:对象的属性,如 bo.companyId
    return getFieldValue(target, annotation.paramName());

    case COLLECTION_FIELD:
    // 场景3:集合元素的属性,如 List<Bo> 取 Bo.companyId
    return getCollectionFieldValues(target, annotation.paramName());

    case NESTED_FIELD:
    // 场景4:嵌套属性,如 bo.companyInfo.companyId
    return getNestedFieldValue(target, annotation.paramName());

    case COLLECTION_NESTED:
    // 场景5:集合中的嵌套属性,如 bo.companyList.companyId
    return getCollectionNestedValues(target, annotation.paramName());

    default:
    throw new BizException("不支持的取值类型: " + valueType);
    }
    }

    /**
    * 提取原始参数
    */

    private Object extractRawParam(String[] paramNames, Object[] args, String paramName) {
    for (int i = 0; i < paramNames.length; i++) {
    if (paramName.equals(paramNames[i])) {
    return args[i];
    }
    }
    throw new BizException("未找到参数: " + paramName);
    }

    /**
    * 通过反射获取对象属性值(使用getter方法)
    */

    private Object getFieldValue(Object obj, String fieldName) {
    try {
    PropertyDescriptor pd = new PropertyDescriptor(fieldName, obj.getClass());
    Method getter = pd.getReadMethod();
    if (getter == null) {
    throw new BizException("属性 " + fieldName + " 没有getter方法");
    }
    return getter.invoke(obj);
    } catch (Exception e) {
    log.error("获取属性值失败: {}", fieldName, e);
    throw new BizException("解析参数失败: " + fieldName);
    }
    }

    /**
    * 获取集合元素的属性值列表
    */

    private List<Object> getCollectionFieldValues(Object obj, String fieldName) {
    if (!(obj instanceof Collection)) {
    throw new BizException("参数不是Collection类型");
    }

    Collection<?> collection = (Collection<?>) obj;
    return collection.stream()
    .map(item -> getFieldValue(item, fieldName))
    .collect(Collectors.toList());
    }

    /**
    * 获取嵌套属性值,如 obj.field1.field2
    */

    private Object getNestedFieldValue(Object obj, String fieldPath) {
    String[] fields = fieldPath.split("\\\\.");
    Object current = obj;

    for (String field : fields) {
    if (current == null) {
    throw new BizException("嵌套属性路径中有null值: " + fieldPath);
    }
    current = getFieldValue(current, field);
    }

    return current;
    }

    /**
    * 获取集合中的嵌套属性值
    */

    private List<Object> getCollectionNestedValues(Object obj, String fieldPath) {
    String[] parts = fieldPath.split("\\\\.");
    if (parts.length != 2) {
    throw new BizException("COLLECTION_NESTED类型需要两级路径,如: companyList.companyId");
    }

    // 第一级:获取集合属性
    Object collectionObj = getFieldValue(obj, parts[0]);
    if (!(collectionObj instanceof Collection)) {
    throw new BizException(parts[0] + "不是Collection类型");
    }

    // 第二级:遍历集合,获取每个元素的属性
    Collection<?> collection = (Collection<?>) collectionObj;
    return collection.stream()
    .map(item -> getFieldValue(item, parts[1]))
    .collect(Collectors.toList());
    }

    /**
    * 执行权限校验
    */

    private boolean checkUserPermission(String userName, Object authValue, AuthObjectType objectType) {
    if (objectType == AuthObjectType.COMPANY) {
    // 单个公司
    Long companyId = convertToLong(authValue);
    return permissionManager.checkCompany(userName, companyId);

    } else {
    // 多个公司
    List<Long> companyIds = convertToLongList(authValue);
    return permissionManager.checkCompanies(userName, companyIds);
    }
    }

    private Long convertToLong(Object value) {
    if (value instanceof Long) {
    return (Long) value;
    }
    if (value instanceof Integer) {
    return ((Integer) value).longValue();
    }
    if (value instanceof String) {
    return Long.parseLong((String) value);
    }
    throw new BizException("无法转换为Long类型: " + value);
    }

    @SuppressWarnings("unchecked")
    private List<Long> convertToLongList(Object value) {
    if (value instanceof Collection) {
    return ((Collection<?>) value).stream()
    .map(this::convertToLong)
    .collect(Collectors.toList());
    }
    throw new BizException("无法转换为Long列表: " + value);
    }
    }

    六、使用示例

    看看实际项目中怎么用这个注解:

    6.1 场景1:最简单的用法

    @RestController
    @RequestMapping("/api/app")
    public class AppController {

    /**
    * 直接参数:companyId就在参数列表里
    */

    @GetMapping("/list")
    @UserPermission
    public Result<List<AppInfo>> listApps(long companyId) {
    // 直接使用companyId,注解自动取值
    return Result.success(appService.listByCompany(companyId));
    }
    }

    6.2 场景2:对象属性

    @Data
    public class AppQueryRequest {
    private Long companyId;
    private String appName;
    private Integer pageNum;
    private Integer pageSize;
    }

    @PostMapping("/query")
    @UserPermission(
    valueType = AuthValueType.OBJECT_FIELD,
    paramName = "companyId"
    )
    public Result<PageInfo<AppInfo>> queryApps(@RequestBody AppQueryRequest request) {
    // 从request.companyId取值
    return Result.success(appService.queryPage(request));
    }

    6.3 场景3:批量操作

    @PostMapping("/batch/delete")
    @UserPermission(
    objectType = AuthObjectType.COMPANIES,
    valueType = AuthValueType.COLLECTION_FIELD,
    paramName = "companyId"
    )
    public Result<Void> batchDelete(@RequestBody List<AppInfo> apps) {
    // 从每个AppInfo对象中提取companyId,组成列表后校验
    // 确保用户对这些companyId都有权限
    appService.batchDelete(apps);
    return Result.success();
    }

    6.4 场景4:嵌套属性

    @Data
    public class ComplexRequest {
    private CompanyInfo companyInfo;

    @Data
    public static class CompanyInfo {
    private Long companyId;
    }
    }

    @PostMapping("/complex")
    @UserPermission(
    valueType = AuthValueType.NESTED_FIELD,
    paramName = "companyInfo.companyId"
    )
    public Result<Object> complexOperation(@RequestBody ComplexRequest request) {
    // 从request.companyInfo.companyId取值
    return Result.success();
    }

    6.5 场景5:类级别默认配置

    @RestController
    @RequestMapping("/api/user")
    @UserPermission(valueType = AuthValueType.OBJECT_FIELD, paramName = "companyId")
    public class UserController {

    /**
    * 继承类上的注解
    */

    @GetMapping("/list")
    public Result<List<UserVO>> listUsers(@RequestParam Long companyId) {
    return Result.success(userService.listByCompany(companyId));
    }

    /**
    * 覆盖类上的注解
    */

    @PostMapping("/batch/query")
    @UserPermission(
    objectType = AuthObjectType.COMPANIES,
    valueType = AuthValueType.COLLECTION_FIELD,
    paramName = "companyId"
    )
    public Result<List<UserVO>> batchQuery(@RequestBody List<CompanyQuery> queries) {
    return Result.success(userService.batchQuery(queries));
    }

    /**
    * 忽略鉴权
    */

    @GetMapping("/public/info")
    @UserPermission(ignore = true)
    public Result<PublicInfo> getPublicInfo() {
    return Result.success(userService.getPublicInfo());
    }
    }

    七、遇到的坑和解决方案

    坑1:参数名获取不到

    问题:编译后参数名变成arg0、arg1,导致按名称取值失败。

    解决:Maven编译插件添加-parameters参数:

    <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
    <compilerArgs>
    <arg>-parameters</arg>
    </compilerArgs>
    </configuration>
    </plugin>

    坑2:循环依赖

    问题:AOP切面注入UserPermissionManager,UserPermissionManager又依赖FeignClient,FeignClient可能依赖AOP,形成循环。

    解决:使用@Lazy注解延迟加载:

    @Aspect
    @Component
    @RequiredArgsConstructor
    public class UserPermissionAspect {

    @Lazy
    private final UserPermissionManager permissionManager;
    }

    坑3:集合参数去重

    问题:批量接口传入的companyIds可能重复,重复调用权限平台浪费资源。

    解决:在UserPermissionManager中先做去重:

    List<Long> distinctIds = companyIds.stream()
    .distinct()
    .collect(Collectors.toList());

    坑4:事务失效

    问题:切面中抛出异常,但业务方法的事务没有回滚。

    解决:确保异常在事务切面之后抛出,调整切面顺序:

    @Order(1) // 数字越小越先执行
    public class UserPermissionAspect {
    // …
    }

    八、编译期校验:把问题扼杀在摇篮里

    注解用起来很方便,但也很容易配错。比如COLLECTION_FIELD必须搭配COMPANIES使用,如果配成COMPANY,运行时就会出错。

    我们可以用注解处理器在编译期就发现这些问题:

    8.1 创建注解处理器

    package com.example.auth.processor;

    import com.example.auth.annotation.UserPermission;
    import com.example.auth.annotation.AuthObjectType;
    import com.example.auth.annotation.AuthValueType;

    import javax.annotation.processing.*;
    import javax.lang.model.SourceVersion;
    import javax.lang.model.element.*;
    import javax.lang.model.type.TypeMirror;
    import javax.tools.Diagnostic;
    import java.util.Set;

    /**
    * UserPermission注解处理器
    * 编译期校验注解配置是否正确
    */

    @SupportedAnnotationTypes("com.example.auth.annotation.UserPermission")
    @SupportedSourceVersion(SourceVersion.RELEASE_8)
    public class UserPermissionProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    for (Element element : roundEnv.getElementsAnnotatedWith(UserPermission.class)) {
    checkAnnotation(element);
    }
    return true;
    }

    private void checkAnnotation(Element element) {
    UserPermission annotation = element.getAnnotation(UserPermission.class);

    // 规则1:COLLECTION_FIELD必须搭配COMPANIES
    if (annotation.valueType() == AuthValueType.COLLECTION_FIELD
    && annotation.objectType() != AuthObjectType.COMPANIES) {
    processingEnv.getMessager().printMessage(
    Diagnostic.Kind.ERROR,
    "当valueType=COLLECTION_FIELD时,objectType必须是COMPANIES",
    element
    );
    }

    // 规则2:COLLECTION_NESTED必须搭配COMPANIES
    if (annotation.valueType() == AuthValueType.COLLECTION_NESTED
    && annotation.objectType() != AuthObjectType.COMPANIES) {
    processingEnv.getMessager().printMessage(
    Diagnostic.Kind.ERROR,
    "当valueType=COLLECTION_NESTED时,objectType必须是COMPANIES",
    element
    );
    }

    // 规则3:index不能小于0
    if (annotation.index() < 0) {
    processingEnv.getMessager().printMessage(
    Diagnostic.Kind.ERROR,
    "index不能小于0",
    element
    );
    }

    // 规则4:RAW类型时,paramName必须存在
    if (annotation.valueType() == AuthValueType.RAW
    && (annotation.paramName() == null || annotation.paramName().isEmpty())) {
    processingEnv.getMessager().printMessage(
    Diagnostic.Kind.ERROR,
    "RAW类型时,paramName不能为空",
    element
    );
    }
    }
    }

    8.2 注册处理器

    在resources/META-INF/services/javax.annotation.processing.Processor文件中:

    com.example.auth.processor.UserPermissionProcessor

    配置好后,如果写错注解,IDE会直接报红:

    @UserPermission(
    valueType = AuthValueType.COLLECTION_FIELD, // 编译错误!
    objectType = AuthObjectType.COMPANY // 应该用COMPANIES
    )
    public Result<?> badMethod() {
    // …
    }

    九、性能优化建议

    随着接入的应用越来越多,有几个性能点需要注意:

    9.1 缓存权限结果

    权限关系相对稳定,可以加一层缓存:

    @Component
    public class CachedUserPermissionManager extends UserPermissionManager {

    private final Cache<String, Boolean> permissionCache =
    Caffeine.newBuilder()
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .maximumSize(10000)
    .build();

    @Override
    public boolean checkCompany(String userName, Long companyId) {
    String key = userName + ":" + companyId;
    return permissionCache.get(key, k -> super.checkCompany(userName, companyId));
    }
    }

    9.2 批量接口合并请求

    对于checkCompanies接口,可以合并同一用户的多次请求:

    // 使用异步批量处理
    public CompletableFuture<Boolean> checkCompaniesAsync(String userName, List<Long> companyIds) {
    // 合并请求,批量调用
    }

    9.3 反射优化

    反射获取属性值有一定开销,可以考虑缓存PropertyDescriptor:

    @Component
    public class FieldReader {

    private final ConcurrentMap<String, PropertyDescriptor> cache = new ConcurrentHashMap<>();

    public Object readField(Object obj, String fieldName) {
    Class<?> clazz = obj.getClass();
    String key = clazz.getName() + "#" + fieldName;

    PropertyDescriptor pd = cache.computeIfAbsent(key, k -> {
    try {
    return new PropertyDescriptor(fieldName, clazz);
    } catch (IntrospectionException e) {
    throw new RuntimeException(e);
    }
    });

    try {
    return pd.getReadMethod().invoke(obj);
    } catch (Exception e) {
    throw new RuntimeException(e);
    }
    }
    }

    十、总结与展望

    通过这次改造,我们实现了:

  • ✅ 统一鉴权:所有接口都用同一套注解,规范统一
  • ✅ 接入简单:开发人员只需要加注解,不用写重复代码
  • ✅ 灵活通用:支持5种常见的取值场景,覆盖95%以上的接口
  • ✅ 安全可靠:编译期校验+运行时检查,双重保障
  • 目前这个注解已经在我们的核心业务上线,覆盖了200+接口。后续还可以扩展:

    • 支持角色鉴权:增加@RolePermission注解
    • 支持数据脱敏:结合注解实现字段级脱敏
    • 支持操作审计:自动记录谁在什么时间操作了什么数据
    • 做成Starter:封装成Spring Boot Starter,供其他项目复用

    如果觉得文章有帮助,欢迎点赞收藏。有问题可以在评论区交流,我会尽量回复。

    在这里插入图片描述

    🌺The End🌺点点关注,收藏不迷路🌺

    赞(0)
    未经允许不得转载:171主机测评 » 实战:手写一个通用Web层鉴权注解,解决水平权限漏洞
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址