找回密码
 立即注册
首页 业界区 业界 Web层接口通用鉴权注解实践(基于JDK8)

Web层接口通用鉴权注解实践(基于JDK8)

酝垓 1 小时前
背景

目前我负责的一个公司内部Java应用,其Web层几乎没有进行水平鉴权,存在着一定的风险,比如A可以看到不属于他的B公司的数据。最近公司进行渗透测试,将这个风险暴露出来,并将修复提上了议程。
由于Web层的接口很多,我希望能用一种较为通用易于接入的方式来完成这个工作。很容易就想到了通过注解方式进行水平鉴权。说来惭愧,我工作了十年多还没有从0到1写一个稍微复杂点的注解,正好利用这个机会进行学习和实践。
我结合了一些现有代码以及DeepSeek(元宝版)的建议,实现了相关功能。为了便于理解,本文在保留适用场景通用性的前提下,删减无关的代码。
鉴权场景

1.png

一个公司可以给一个或多个用户授权,一个用户可以被一个或多个公司授权。
用户只能查看被授权公司的数据。
架构

2.png

实现

注解定义
  1. @Target({ElementType.TYPE, ElementType.METHOD})
  2. @Retention(RetentionPolicy.RUNTIME)
  3. @Documented
  4. public @interface UserPermission {
  5.   /** 鉴权对象类型,如果是List,应使用COMPANIES */
  6.   AuthObjectTypeEnum objectType() default AuthObjectTypeEnum.COMPANY;
  7.   /** 鉴权对象值类型,定义如何取得用来鉴权的对象 */
  8.   AuthObjectValueEnum valueType() default AuthObjectValueEnum.RARE;
  9.   /** 鉴权参数序号. 0~n表示第x个参数对象的内部成员变量,仅当 valueType不为RARE时有效 */
  10.   int index() default 0;
  11.   /** 鉴权参数名称, 如果是多级的,使用'.'分割,比如companyInfo.companyId */
  12.   String paramName() default "companyId";
  13.   /** 是否忽略鉴权,用于覆盖类上有默认注解的场景,此时接口不需要鉴权 */
  14.   boolean isIgnore() default false;
  15. }
复制代码
两个枚举的取值范围是
  1. /**
  2. * 授权对象类型
  3. *
  4. */
  5. @Getter
  6. public enum AuthObjectTypeEnum {
  7.   COMPANY(1, "单个企业"),
  8.   COMPANIES(2, "多个企业");
  9.   private final int value;
  10.   private final String des;
  11.   AuthObjectTypeEnum(int value, String des) {
  12.     this.value = value;
  13.     this.des = des;
  14.   }
  15. }
复制代码
  1. /**
  2. * 授权对象参数类型,即该参数是以何种形式出现在形参表的
  3. *
  4. */
  5. @Getter
  6. public enum AuthObjectValueEnum {
  7.   /** 参数直接按原始值出现 */
  8.   RARE(1, "原始参数"),
  9.   /** eg1. 入参是A,取A.companyId, eg2. 入参是B,取B.companyIds */
  10.   OBJECT_FIELD(2, "对象的属性"),
  11.   /** eg. 入参是List objects,取A.companyId */
  12.   COLLECTION_FIELD(3, "集合元素的属性"),
  13.   /** eg. 入参是A,取A.companyInfo.companyId, A.B.companyIds */
  14.   OBJECT_SUB_FIELD(4, "对象的属性的属性"),
  15.   /** eg. 入参是A,取A.companyList中的companyId */
  16.   OBJECT_COLLECTION_FIELD(5, "对象的属性中集合元素的属性"),
  17.   ;
  18.   private final int value;
  19.   private final String des;
  20.   AuthObjectValueEnum(int value, String des) {
  21.     this.value = value;
  22.     this.des = des;
  23.   }
  24. }
复制代码
鉴权辅助类

调用外部服务(本例是公司-用户关系管理平台),获取一个用户是否有单个或多个公司的权限。
  1. /**
  2. * 公司-用户关系管理平台 用户鉴权服务封装
  3. *
  4. */
  5. @Component
  6. public class UserPermissionManager {
  7.   @Resource private UserPermissionFeignService userPermissionFeignService;
  8.   /**
  9.    * 检测用户是否对公司的数据拥有访问权限
  10.    *
  11.    * @param userName
  12.    * @param companyId
  13.    * @return
  14.    */
  15.   public boolean checkCompany(String userName, long companyId) {
  16.     return checkResult(userPermissionFeignService.checkCompany(userName, companyId));
  17.   }
  18.   /**
  19.    * 批量检测用户是否对所有指定的公司的数据都拥有访问权限
  20.    *
  21.    * @param userName
  22.    * @param companyIds
  23.    * @return
  24.    */
  25.   public boolean checkCompanies(String userName, List<Long> companyIds) {
  26.     if (CollectionUtils.isEmpty(companyIds)) {
  27.       throw new BizException("鉴权公司id列表不能为空");
  28.     }
  29.     // 去重
  30.     companyIds = companyIds.stream().distinct().collect(Collectors.toList());
  31.     return companyIds.size() == 1
  32.         ? checkResult(userPermissionFeignService.checkCompany(userName, companyIds.get(0)))
  33.         : checkResult(userPermissionFeignService.checkCompanies(userName, companyIds));
  34.   }
  35.   private boolean checkResult(ResultVO<Boolean> result) {
  36.     if (result == null
  37.         || result.getCode() != HttpCodeConstants.SUCCESS_CODE
  38.         || result.getData() == null) {
  39.       throw new BizException("调用平台进行用户企业数据权限鉴权失败");
  40.     }
  41.     return BooleanUtils.isTrue(result.getData());
  42.   }
  43. }
复制代码
AOP切面

鉴权功能的核心,注解只有在定义了对应的处理切面后,才能发挥作用。
  1. @Aspect
  2. @Component
  3. public class UserPermissionAspect {
  4.   @Resource UserPermissionManager userPermissionManager;
  5.   /** 定义切点 */
  6.   @Pointcut("execution(public * com.yourcompany.xxproject.web.controller..*.*(..))")
  7.   public void privilege() {}
  8.   @Around("privilege()")
  9.   public Object isAccessMethod(ProceedingJoinPoint joinPoint) throws Throwable {
  10.     // 获取方法签名和注解信息
  11.     MethodSignature signature = (MethodSignature) joinPoint.getSignature();
  12.     Method method = signature.getMethod();
  13.     Class targetClass = signature.getDeclaringType();
  14.     UserPermission permissionOnClass =
  15.         (UserPermission) targetClass.getAnnotation(UserPermission.class);
  16.     UserPermission permissionOnMethod = method.getAnnotation(UserPermission.class);
  17.     // 优先取方法上的注解
  18.     UserPermission permissionAnnotation =
  19.         permissionOnClass != null ? permissionOnClass : permissionOnMethod;
  20.     if (permissionAnnotation == null || permissionAnnotation.isIgnore()) {
  21.       // 如果没有注解,或注解的策略是忽略,直接放行
  22.       return joinPoint.proceed();
  23.     }
  24.     ServletRequestAttributes servletRequestAttributes =
  25.         (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  26.     if (servletRequestAttributes == null) {
  27.       throw new BizException("请求中缺少属性信息");
  28.     }
  29.     UserInfoVO userVo = (UserInfoVO) servletRequestAttributes.getRequest().getAttribute("userVo");
  30.     if (userVo == null || StringUtils.isBlank(userVo.getUserName())) {
  31.       throw new BizException("请求中缺少用户信息或不完整");
  32.     }
  33.     String userName = userVo.getUserName();
  34.     // admin直接放行
  35.     if (UserUtil.isAdminOrSystem(userVo.getUserName())) {
  36.       return joinPoint.proceed();
  37.     }
  38.     try {
  39.       Object authValue = extractAuthValue(joinPoint, permissionAnnotation);
  40.       boolean hasPermission =
  41.           checkUserPermission(userName, authValue, permissionAnnotation.objectType());
  42.       if (!hasPermission) {
  43.         LoggerUtils.error(
  44.             String.format(
  45.                 "用户%s没有%s类型对象%s的访问权限, 拒绝访问",
  46.                 userName, permissionAnnotation.objectType().getDes(), authValue));
  47.         throw new BizException("权限不足,无法访问对应的数据。请确认当前用户是待访问数据所属的企业/企业组的成员。");
  48.       }
  49.     } catch (BizException e) {
  50.       throw e;
  51.     } catch (Exception e) {
  52.       LoggerUtils.error("权限校验失败,发生异常", e);
  53.       throw new BizException("权限校验失败,发生异常", e);
  54.     }
  55.     return joinPoint.proceed();
  56.   }
  57.   /**
  58.    * 从方法参数中提取鉴权对象的值
  59.    *
  60.    * @param joinPoint
  61.    * @param annotation
  62.    * @return
  63.    */
  64.   private Object extractAuthValue(ProceedingJoinPoint joinPoint, UserPermission annotation) {
  65.     MethodSignature signature = (MethodSignature) joinPoint.getSignature();
  66.     String[] parameterNames = signature.getParameterNames();
  67.     Object[] args = joinPoint.getArgs();
  68.     if (parameterNames == null || parameterNames.length == 0 || args == null || args.length == 0) {
  69.       throw new BizException("用户数据鉴权解析参数时,调用方法参数表为空");
  70.     }
  71.     if (annotation.valueType().getValue() == AuthObjectValueEnum.RARE.getValue()) {
  72.       // 直接按名称获取
  73.       for (int i = 0; i < parameterNames.length; i++) {
  74.         if (StringUtils.equals(parameterNames[i], annotation.paramName())) {
  75.           return args[i];
  76.         }
  77.       }
  78.       throw new BizException("用户数据鉴权解析参数时,未在形参表找到指定的鉴权参数");
  79.     }
  80.     // 参数所在的参数表的位置
  81.     int index = annotation.index();
  82.     // 其他情况,从入参对象获取
  83.     if (index < 0 || index >= args.length) {
  84.       throw new BizException("用户数据鉴权解析参数时索引越界: " + index + ",参数总数: " + args.length);
  85.     }
  86.     Object targetParam = args[index];
  87.     if (targetParam == null) {
  88.       throw new BizException("用户数据鉴权解析参数时第" + index + "个参数为null");
  89.     }
  90.     if (annotation.valueType().getValue() == AuthObjectValueEnum.OBJECT_FIELD.getValue()) {
  91.       // 从对象属性中获取, 比如A.companyId、A.companyIds
  92.       return extractFieldValue(targetParam, annotation.paramName());
  93.     } else if (annotation.valueType().getValue()
  94.         == AuthObjectValueEnum.COLLECTION_FIELD.getValue()) {
  95.       // 从集合对象的属性中获取,比如List,取A.companyId
  96.       // 此时必然是一个List(不考虑去重)
  97.       return extractListFieldValue(targetParam, annotation.paramName());
  98.     } else if (annotation.valueType().getValue()
  99.         == AuthObjectValueEnum.OBJECT_SUB_FIELD.getValue()) {
  100.       // 从对象的属性的属性获取,比如A.companyInfo.companyId
  101.       String[] fieldNames = annotation.paramName().split("\\.");
  102.       Object subTargetParam = extractFieldValue(targetParam, fieldNames[0]);
  103.       return extractFieldValue(subTargetParam, fieldNames[1]);
  104.     } else if (annotation.valueType().getValue()
  105.         == AuthObjectValueEnum.OBJECT_COLLECTION_FIELD.getValue()) {
  106.       // 从对象的集合属性中获取,比如A.companyList, 取companyList.companyId
  107.       String[] fieldNames = annotation.paramName().split("\\.");
  108.       Object subTargetParam = extractFieldValue(targetParam, fieldNames[0]);
  109.       return extractListFieldValue(subTargetParam, fieldNames[1]);
  110.     } else {
  111.       throw new BizException("用户数据鉴权解析参数, 参数值类型配置错误");
  112.     }
  113.   }
  114.   /**
  115.    * 实际的鉴权
  116.    *
  117.    * @param authValue
  118.    * @param authObjectTypeEnum
  119.    * @return
  120.    */
  121.   private boolean checkUserPermission(
  122.       String userName, Object authValue, AuthObjectTypeEnum authObjectTypeEnum) {
  123.     if (authObjectTypeEnum.getValue() == AuthObjectTypeEnum.COMPANY.getValue()) {
  124.       return userPermissionManager.checkCompany(userName, getLongValue(authValue));
  125.     } else if (authObjectTypeEnum.getValue() == AuthObjectTypeEnum.COMPANIES.getValue()) {
  126.       return userPermissionManager.checkCompanies(userName, getLongListValue(authValue));
  127.     } else {
  128.       throw new BizException("按用户维度鉴权,该接口的鉴权对象类型非法");
  129.     }
  130.   }
  131.   private static long getLongValue(Object authValue) {
  132.     if (authValue instanceof Long) {
  133.       return (long) authValue;
  134.     } else if (authValue instanceof Integer) {
  135.       return (int) authValue;
  136.     } else if (authValue instanceof String) {
  137.       return Long.parseLong((String) authValue);
  138.     } else {
  139.       throw new BizException("按用户维度鉴权,企业id不是可处理的类型");
  140.     }
  141.   }
  142.   private static List<Long> getLongListValue(Object authValue) {
  143.     if (!(authValue instanceof Collection)) {
  144.       throw new BizException("按用户维度鉴权,企业id列表不是Collection类型");
  145.     }
  146.     Collection<?> col = (Collection<?>) authValue;
  147.     return col.stream().map(UserPermissionAspect::getLongValue).collect(Collectors.toList());
  148.   }
  149.   /**
  150.    * 通过反射从对象中提取字段值
  151.    *
  152.    * @param targetObject
  153.    * @param fieldName
  154.    */
  155.   private Object extractFieldValue(Object targetObject, String fieldName) {
  156.     if (targetObject == null || fieldName == null || fieldName.isEmpty()) {
  157.       throw new IllegalArgumentException("目标对象和字段名不能为空");
  158.     }
  159.     try {
  160.       PropertyDescriptor pd = new PropertyDescriptor(fieldName, targetObject.getClass());
  161.       Method getter = pd.getReadMethod();
  162.       if (getter == null) {
  163.         throw new BizException("字段 '" + fieldName + "' 没有对应的getter方法");
  164.       }
  165.       return getter.invoke(targetObject);
  166.     } catch (IntrospectionException e) {
  167.       // 如果找不到标准Getter,不尝试直接访问字段即field.setAccessible(true); field.get(targetObject);
  168.       // 此注解处理的都是Controller入参,不太可能没有Getter/Setter,没必要写冗余的处理逻辑
  169.       throw new BizException("调用字段 '" + fieldName + "' 没有找到标准getter方法时发生错误", e);
  170.     } catch (IllegalAccessException | InvocationTargetException e) {
  171.       throw new BizException("调用字段 '" + fieldName + "' 的getter方法时发生错误", e);
  172.     }
  173.   }
  174.   private List<Long> extractListFieldValue(Object targetParam, String fieldName) {
  175.     if (!(targetParam instanceof Collection)) {
  176.       throw new BizException("按用户维度鉴权,待处理对象列表不是Collection类型");
  177.     }
  178.     Collection<?> col = (Collection<?>) targetParam;
  179.     List<Long> result = Lists.newArrayList();
  180.     for (Object obj : col) {
  181.       if (obj == null) {
  182.         throw new BizException("按用户维度鉴权,集合中包含null元素,无法访问其字段。");
  183.       }
  184.       result.add(getLongValue(extractFieldValue(obj, fieldName)));
  185.     }
  186.     return result;
  187.   }
  188. }
复制代码
设计思路

目前的处理逻辑并不是第一版,和其他功能一样也是从最基础的功能开始,一点一点往上加的。简述一下设计思路:

  • 最常见的场景,使用方式最简单。最常见的场景是鉴权所需参数(本例是公司id即companyId)直接出现在方法的形参表里。这种情况只需要直接给方法打@UserPermission注解。
  • 尽量减少重复配置。如果一个Controller类的大部分入参形式是一样的,那么直接在Controller类上打注解。
  • 预处理重复参数。多个comanyId鉴权时,在调用外部服务前,先做去重,可能可以减少服务提供方的系统开销。当然,这里的系统提供方的代码也是我写的,我会在提供方代码里也做一次去重。
  • 直接放行的场景处理。如admin用户,不需要做任何权限配置就可以访问,这块逻辑可以放在切面,也可以放在UserPermissionManger
  • 对象取值优先使用getter。Web层的Controller,入参对象一般都是pojo,直接调用getter效率更好。如果没有pojo,那就需要使用反射方法来直接读取属性值。
使用示例

不同场景下,注解属性字段的配置方式如下表:
场景名称和实例objectTypevalueTypeindexparamNameisIgnore首个参数,且参数名称为默认名称

func(long companyId)默认(COMPANY)默认(RARE)默认(0)默认(companyId)默认

(false)首个参数,id列表

func(List companyIds)COMPANIESRARE默认(0)companyIds默认

(false)首个参数的属性

func(TaBO bo)

bo.companyId默认(COMPANY)OBJECT_FIELD默认(0)默认(companyId)默认

(false)首个参数的属性

func(TbBO bo)

bo.companyIdsCOMPANIESOBJECT_FIELD默认(0)companyIds默认

(false)第二个参数的属性

func(TaBO abo, TbBO bbo)

bbo.companyId默认(COMPANY)OBJECT_FIELD1默认(companyId)默认

(false)首个集合参数的属性

func(List list)

abo.companyIdCOMPANIESCOLLECTION_FIELD默认(0)默认(companyId)默认

(false)首个参数属性的属性

func(TaBO abo)

abo.companyInfo.companyId默认(COMPANY)OBJECT_SUB_FIELD默认(0)companyInfo.companyId默认

(false)首个参数的属性是一个集合,这个集合元素的属性

func(TaBO abo)

abo.companyInfoList

companyInfo.companyIdCOMPANIESOBJECT_COLLECTION_FIELD默认(0)companyInfoList.companyId默认

(false)Controller类本身有鉴权注解,但是当前方法不需要鉴权任意值任意值任意值任意值true边界场景处理

看起来这个注解已经非常全面,可以能处理绝大多数场景了,是吗?
话不能说绝对,在一个运行多年的系统中,你会看到这种入参:Task queryTask(Long taskId),companyId字段是Task的属性,现在注解是不是束手无策了?
有两个选择:

  • 进一步扩展注解的适用场景,或者为这种场景编写专属的注解
  • 直接编码,先查Task,取companyId,手动调用userPermissionManger
我选用了方案二。方案一固然可以将更多的接口接入转化为注解,但是也要根据实际情况,如果适用的接口并不多,新增的注解相关代码也意味着额外的维护成本。
编译期校验

根据注解的使用实例,可以看到objectType和valueType是有一定的对应关系的,valueType=COLLECTION_FIELD或OBJECT_COLLECTION_FIELD时,objectType必然是COMPANIES。如果写错了,也没测试出来,上线以后还要重新改,可不是什么好事。
此外,index显然是大于等于0的。如何将这些限制的检查放到编译期,从根源上防止配置错误呢?
方法是有的,而且有好几种。但是有些方法需要增加三方库依赖及对应的配置,我选择了一个最简单的方案,利用JDK8的原生能力就能完成,不过缺点是需要把注解相关代码移到业务代码之外的module,并且确保这个module比业务module先编译。步骤如下:

  • 首先,将你的注解相关代码(切面代码除外)移到业务层之外的module。不推荐放到对外打包的module,因此我选择新建了一个。注意处理各个module的依赖,确保业务层依赖了这个module
3.png


  • 编写注解处理器代码,对注解的配置值进行校验
[code]/** * UserPermission注解处理器,在编译期校验注解是否配置正确 * * 如果和业务代码在同一模块,需要做很多额外的编译配置,或引入二方包并配置。简单起见单独抽一个模块 * */@SupportedAnnotationTypes("com.yourcompany.xxproject.annotation.UserPermission")@SupportedSourceVersion(SourceVersion.RELEASE_8)public class UserPermissionProcessor extends AbstractProcessor {  @Override  public boolean process(Set

相关推荐

您需要登录后才可以回帖 登录 | 立即注册