博客 / 詳情

返回

Web層接口通用鑑權註解實踐(基於JDK8)

背景

目前我負責的一個公司內部Java應用,其Web層幾乎沒有進行水平鑑權,存在着一定的風險,比如A可以看到不屬於他的B公司的數據。最近公司進行滲透測試,將這個風險暴露出來,並將修復提上了議程。

由於Web層的接口很多,我希望能用一種較為通用易於接入的方式來完成這個工作。很容易就想到了通過註解方式進行水平鑑權。説來慚愧,我工作了十年多還沒有從0到1寫一個稍微複雜點的註解,正好利用這個機會進行學習和實踐。

我結合了一些現有代碼以及DeepSeek(元寶版)的建議,實現了相關功能。為了便於理解,本文在保留適用場景通用性的前提下,刪減無關的代碼。

鑑權場景

image

一個公司可以給一個或多個用户授權,一個用户可以被一個或多個公司授權。

用户只能查看被授權公司的數據。

架構

image

實現

註解定義

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

  /** 鑑權對象類型,如果是List,應使用COMPANIES */
  AuthObjectTypeEnum objectType() default AuthObjectTypeEnum.COMPANY;

  /** 鑑權對象值類型,定義如何取得用來鑑權的對象 */
  AuthObjectValueEnum valueType() default AuthObjectValueEnum.RARE;

  /** 鑑權參數序號. 0~n表示第x個參數對象的內部成員變量,僅當 valueType不為RARE時有效 */
  int index() default 0;

  /** 鑑權參數名稱, 如果是多級的,使用'.'分割,比如companyInfo.companyId */
  String paramName() default "companyId";

  /** 是否忽略鑑權,用於覆蓋類上有默認註解的場景,此時接口不需要鑑權 */
  boolean isIgnore() default false;
}

兩個枚舉的取值範圍是

/**
 * 授權對象類型
 *
 */
@Getter
public enum AuthObjectTypeEnum {
  COMPANY(1, "單個企業"),
  COMPANIES(2, "多個企業");

  private final int value;
  private final String des;

  AuthObjectTypeEnum(int value, String des) {
    this.value = value;
    this.des = des;
  }
}
/**
 * 授權對象參數類型,即該參數是以何種形式出現在形參表的
 *
 */
@Getter
public enum AuthObjectValueEnum {
  /** 參數直接按原始值出現 */
  RARE(1, "原始參數"),

  /** eg1. 入參是A,取A.companyId, eg2. 入參是B,取B.companyIds */
  OBJECT_FIELD(2, "對象的屬性"),

  /** eg. 入參是List<A> objects,取A.companyId */
  COLLECTION_FIELD(3, "集合元素的屬性"),

  /** eg. 入參是A,取A.companyInfo.companyId, A.B.companyIds */
  OBJECT_SUB_FIELD(4, "對象的屬性的屬性"),

  /** eg. 入參是A,取A.companyList中的companyId */
  OBJECT_COLLECTION_FIELD(5, "對象的屬性中集合元素的屬性"),
  ;

  private final int value;
  private final String des;

  AuthObjectValueEnum(int value, String des) {
    this.value = value;
    this.des = des;
  }
}

鑑權輔助類

調用外部服務(本例是公司-用户關係管理平台),獲取一個用户是否有單個或多個公司的權限。

/**
 * 公司-用户關係管理平台 用户鑑權服務封裝
 *
 */
@Component
public class UserPermissionManager {

  @Resource private UserPermissionFeignService userPermissionFeignService;

  /**
   * 檢測用户是否對公司的數據擁有訪問權限
   *
   * @param userName
   * @param companyId
   * @return
   */
  public boolean checkCompany(String userName, long companyId) {
    return checkResult(userPermissionFeignService.checkCompany(userName, companyId));
  }

  /**
   * 批量檢測用户是否對所有指定的公司的數據都擁有訪問權限
   *
   * @param userName
   * @param companyIds
   * @return
   */
  public boolean checkCompanies(String userName, List<Long> companyIds) {
    if (CollectionUtils.isEmpty(companyIds)) {
      throw new BizException("鑑權公司id列表不能為空");
    }
    // 去重
    companyIds = companyIds.stream().distinct().collect(Collectors.toList());

    return companyIds.size() == 1
        ? checkResult(userPermissionFeignService.checkCompany(userName, companyIds.get(0)))
        : checkResult(userPermissionFeignService.checkCompanies(userName, companyIds));
  }

  private boolean checkResult(ResultVO<Boolean> result) {
    if (result == null
        || result.getCode() != HttpCodeConstants.SUCCESS_CODE
        || result.getData() == null) {
      throw new BizException("調用平台進行用户企業數據權限鑑權失敗");
    }
    return BooleanUtils.isTrue(result.getData());
  }
}

AOP切面

鑑權功能的核心,註解只有在定義了對應的處理切面後,才能發揮作用。

@Aspect
@Component
public class UserPermissionAspect {

  @Resource UserPermissionManager userPermissionManager;

  /** 定義切點 */
  @Pointcut("execution(public * com.yourcompany.xxproject.web.controller..*.*(..))")
  public void privilege() {}

  @Around("privilege()")
  public Object isAccessMethod(ProceedingJoinPoint joinPoint) throws Throwable {
    // 獲取方法簽名和註解信息
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    Class targetClass = signature.getDeclaringType();

    UserPermission permissionOnClass =
        (UserPermission) targetClass.getAnnotation(UserPermission.class);
    UserPermission permissionOnMethod = method.getAnnotation(UserPermission.class);
    // 優先取方法上的註解
    UserPermission permissionAnnotation =
        permissionOnClass != null ? permissionOnClass : permissionOnMethod;
    if (permissionAnnotation == null || permissionAnnotation.isIgnore()) {
      // 如果沒有註解,或註解的策略是忽略,直接放行
      return joinPoint.proceed();
    }
    ServletRequestAttributes servletRequestAttributes =
        (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    if (servletRequestAttributes == null) {
      throw new BizException("請求中缺少屬性信息");
    }
    UserInfoVO userVo = (UserInfoVO) servletRequestAttributes.getRequest().getAttribute("userVo");
    if (userVo == null || StringUtils.isBlank(userVo.getUserName())) {
      throw new BizException("請求中缺少用户信息或不完整");
    }
    String userName = userVo.getUserName();
    // admin直接放行
    if (UserUtil.isAdminOrSystem(userVo.getUserName())) {
      return joinPoint.proceed();
    }
    try {
      Object authValue = extractAuthValue(joinPoint, permissionAnnotation);
      boolean hasPermission =
          checkUserPermission(userName, authValue, permissionAnnotation.objectType());
      if (!hasPermission) {
        LoggerUtils.error(
            String.format(
                "用户%s沒有%s類型對象%s的訪問權限, 拒絕訪問",
                userName, permissionAnnotation.objectType().getDes(), authValue));
        throw new BizException("權限不足,無法訪問對應的數據。請確認當前用户是待訪問數據所屬的企業/企業組的成員。");
      }
    } catch (BizException e) {
      throw e;
    } catch (Exception e) {
      LoggerUtils.error("權限校驗失敗,發生異常", e);
      throw new BizException("權限校驗失敗,發生異常", e);
    }
    return joinPoint.proceed();
  }

  /**
   * 從方法參數中提取鑑權對象的值
   *
   * @param joinPoint
   * @param annotation
   * @return
   */
  private Object extractAuthValue(ProceedingJoinPoint joinPoint, UserPermission annotation) {
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    String[] parameterNames = signature.getParameterNames();
    Object[] args = joinPoint.getArgs();
    if (parameterNames == null || parameterNames.length == 0 || args == null || args.length == 0) {
      throw new BizException("用户數據鑑權解析參數時,調用方法參數表為空");
    }

    if (annotation.valueType().getValue() == AuthObjectValueEnum.RARE.getValue()) {
      // 直接按名稱獲取
      for (int i = 0; i < parameterNames.length; i++) {
        if (StringUtils.equals(parameterNames[i], annotation.paramName())) {
          return args[i];
        }
      }
      throw new BizException("用户數據鑑權解析參數時,未在形參表找到指定的鑑權參數");
    }

    // 參數所在的參數表的位置
    int index = annotation.index();
    // 其他情況,從入參對象獲取
    if (index < 0 || index >= args.length) {
      throw new BizException("用户數據鑑權解析參數時索引越界: " + index + ",參數總數: " + args.length);
    }

    Object targetParam = args[index];
    if (targetParam == null) {
      throw new BizException("用户數據鑑權解析參數時第" + index + "個參數為null");
    }
    if (annotation.valueType().getValue() == AuthObjectValueEnum.OBJECT_FIELD.getValue()) {
      // 從對象屬性中獲取, 比如A.companyId、A.companyIds
      return extractFieldValue(targetParam, annotation.paramName());
    } else if (annotation.valueType().getValue()
        == AuthObjectValueEnum.COLLECTION_FIELD.getValue()) {
      // 從集合對象的屬性中獲取,比如List<A>,取A.companyId
      // 此時必然是一個List(不考慮去重)
      return extractListFieldValue(targetParam, annotation.paramName());
    } else if (annotation.valueType().getValue()
        == AuthObjectValueEnum.OBJECT_SUB_FIELD.getValue()) {
      // 從對象的屬性的屬性獲取,比如A.companyInfo.companyId
      String[] fieldNames = annotation.paramName().split("\\.");
      Object subTargetParam = extractFieldValue(targetParam, fieldNames[0]);
      return extractFieldValue(subTargetParam, fieldNames[1]);
    } else if (annotation.valueType().getValue()
        == AuthObjectValueEnum.OBJECT_COLLECTION_FIELD.getValue()) {
      // 從對象的集合屬性中獲取,比如A.companyList, 取companyList.companyId
      String[] fieldNames = annotation.paramName().split("\\.");
      Object subTargetParam = extractFieldValue(targetParam, fieldNames[0]);
      return extractListFieldValue(subTargetParam, fieldNames[1]);
    } else {
      throw new BizException("用户數據鑑權解析參數, 參數值類型配置錯誤");
    }
  }

  /**
   * 實際的鑑權
   *
   * @param authValue
   * @param authObjectTypeEnum
   * @return
   */
  private boolean checkUserPermission(
      String userName, Object authValue, AuthObjectTypeEnum authObjectTypeEnum) {
    if (authObjectTypeEnum.getValue() == AuthObjectTypeEnum.COMPANY.getValue()) {
      return userPermissionManager.checkCompany(userName, getLongValue(authValue));
    } else if (authObjectTypeEnum.getValue() == AuthObjectTypeEnum.COMPANIES.getValue()) {
      return userPermissionManager.checkCompanies(userName, getLongListValue(authValue));
    } else {
      throw new BizException("按用户維度鑑權,該接口的鑑權對象類型非法");
    }
  }

  private static long getLongValue(Object authValue) {
    if (authValue instanceof Long) {
      return (long) authValue;
    } else if (authValue instanceof Integer) {
      return (int) authValue;
    } else if (authValue instanceof String) {
      return Long.parseLong((String) authValue);
    } else {
      throw new BizException("按用户維度鑑權,企業id不是可處理的類型");
    }
  }

  private static List<Long> getLongListValue(Object authValue) {
    if (!(authValue instanceof Collection)) {
      throw new BizException("按用户維度鑑權,企業id列表不是Collection類型");
    }
    Collection<?> col = (Collection<?>) authValue;
    return col.stream().map(UserPermissionAspect::getLongValue).collect(Collectors.toList());
  }

  /**
   * 通過反射從對象中提取字段值
   *
   * @param targetObject
   * @param fieldName
   */
  private Object extractFieldValue(Object targetObject, String fieldName) {
    if (targetObject == null || fieldName == null || fieldName.isEmpty()) {
      throw new IllegalArgumentException("目標對象和字段名不能為空");
    }
    try {
      PropertyDescriptor pd = new PropertyDescriptor(fieldName, targetObject.getClass());
      Method getter = pd.getReadMethod();
      if (getter == null) {
        throw new BizException("字段 '" + fieldName + "' 沒有對應的getter方法");
      }
      return getter.invoke(targetObject);
    } catch (IntrospectionException e) {
      // 如果找不到標準Getter,不嘗試直接訪問字段即field.setAccessible(true); field.get(targetObject);
      // 此註解處理的都是Controller入參,不太可能沒有Getter/Setter,沒必要寫冗餘的處理邏輯
      throw new BizException("調用字段 '" + fieldName + "' 沒有找到標準getter方法時發生錯誤", e);
    } catch (IllegalAccessException | InvocationTargetException e) {
      throw new BizException("調用字段 '" + fieldName + "' 的getter方法時發生錯誤", e);
    }
  }

  private List<Long> extractListFieldValue(Object targetParam, String fieldName) {
    if (!(targetParam instanceof Collection)) {
      throw new BizException("按用户維度鑑權,待處理對象列表不是Collection類型");
    }
    Collection<?> col = (Collection<?>) targetParam;
    List<Long> result = Lists.newArrayList();
    for (Object obj : col) {
      if (obj == null) {
        throw new BizException("按用户維度鑑權,集合中包含null元素,無法訪問其字段。");
      }
      result.add(getLongValue(extractFieldValue(obj, fieldName)));
    }
    return result;
  }
}

設計思路

目前的處理邏輯並不是第一版,和其他功能一樣也是從最基礎的功能開始,一點一點往上加的。簡述一下設計思路:

  1. 最常見的場景,使用方式最簡單。最常見的場景是鑑權所需參數(本例是公司id即companyId)直接出現在方法的形參表裏。這種情況只需要直接給方法打@UserPermission註解。

  2. 儘量減少重複配置。如果一個Controller類的大部分入參形式是一樣的,那麼直接在Controller類上打註解。

  3. 預處理重複參數。多個comanyId鑑權時,在調用外部服務前,先做去重,可能可以減少服務提供方的系統開銷。當然,這裏的系統提供方的代碼也是我寫的,我會在提供方代碼裏也做一次去重。

  4. 直接放行的場景處理。如admin用户,不需要做任何權限配置就可以訪問,這塊邏輯可以放在切面,也可以放在UserPermissionManger

  5. 對象取值優先使用getter。Web層的Controller,入參對象一般都是pojo,直接調用getter效率更好。如果沒有pojo,那就需要使用反射方法來直接讀取屬性值。

使用示例

不同場景下,註解屬性字段的配置方式如下表:

場景名稱和實例 objectType valueType index paramName isIgnore
首個參數,且參數名稱為默認名稱

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

(false)
首個參數,id列表

func(List companyIds)
COMPANIES RARE 默認(0) companyIds 默認

(false)
首個參數的屬性

func(TaBO bo)

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

(false)
首個參數的屬性

func(TbBO bo)

bo.companyIds
COMPANIES OBJECT_FIELD 默認(0) companyIds 默認

(false)
第二個參數的屬性

func(TaBO abo, TbBO bbo)

bbo.companyId
默認(COMPANY) OBJECT_FIELD 1 默認(companyId) 默認

(false)
首個集合參數的屬性

func(List list)

abo.companyId
COMPANIES COLLECTION_FIELD 默認(0) 默認(companyId) 默認

(false)
首個參數屬性的屬性

func(TaBO abo)

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

(false)
首個參數的屬性是一個集合,這個集合元素的屬性

func(TaBO abo)

abo.companyInfoList

companyInfo.companyId
COMPANIES OBJECT_COLLECTION_FIELD 默認(0) companyInfoList.companyId 默認

(false)
Controller類本身有鑑權註解,但是當前方法不需要鑑權 任意值 任意值 任意值 任意值 true

邊界場景處理

看起來這個註解已經非常全面,可以能處理絕大多數場景了,是嗎?

話不能説絕對,在一個運行多年的系統中,你會看到這種入參:Task queryTask(Long taskId),companyId字段是Task的屬性,現在註解是不是束手無策了?

有兩個選擇:

  1. 進一步擴展註解的適用場景,或者為這種場景編寫專屬的註解

  2. 直接編碼,先查Task,取companyId,手動調用userPermissionManger

我選用了方案二。方案一固然可以將更多的接口接入轉化為註解,但是也要根據實際情況,如果適用的接口並不多,新增的註解相關代碼也意味着額外的維護成本。

編譯期校驗

根據註解的使用實例,可以看到objectType和valueType是有一定的對應關係的,valueType=COLLECTION_FIELD或OBJECT_COLLECTION_FIELD時,objectType必然是COMPANIES。如果寫錯了,也沒測試出來,上線以後還要重新改,可不是什麼好事。

此外,index顯然是大於等於0的。如何將這些限制的檢查放到編譯期,從根源上防止配置錯誤呢?

方法是有的,而且有好幾種。但是有些方法需要增加三方庫依賴及對應的配置,我選擇了一個最簡單的方案,利用JDK8的原生能力就能完成,不過缺點是需要把註解相關代碼移到業務代碼之外的module,並且確保這個module比業務module先編譯。步驟如下:

  1. 首先,將你的註解相關代碼(切面代碼除外)移到業務層之外的module。不推薦放到對外打包的module,因此我選擇新建了一個。注意處理各個module的依賴,確保業務層依賴了這個module

image

  1. 編寫註解處理器代碼,對註解的配置值進行校驗
/**
 * UserPermission註解處理器,在編譯期校驗註解是否配置正確
 *
 * 如果和業務代碼在同一模塊,需要做很多額外的編譯配置,或引入二方包並配置。簡單起見單獨抽一個模塊
 *
 */
@SupportedAnnotationTypes("com.yourcompany.xxproject.annotation.UserPermission")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class UserPermissionProcessor extends AbstractProcessor {

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

  private void checkUserPermissionUsage(Element element) {
    AnnotationMirror userPermissionMirror =
        getAnnotationMirror(element, "com.yourcompany.xxproject.annotation.UserPermission");
    if (userPermissionMirror == null) {
      return;
    }
    Map<? extends ExecutableElement, ? extends AnnotationValue> elementValues =
        processingEnv.getElementUtils().getElementValuesWithDefaults(userPermissionMirror);

    checkValueTypeObjectTypeConstraint(element, userPermissionMirror, elementValues);
    checkIndexConstraint(element, userPermissionMirror, elementValues);
  }

  private void checkValueTypeObjectTypeConstraint(
      Element element,
      AnnotationMirror annotationMirror,
      Map<? extends ExecutableElement, ? extends AnnotationValue> values) {
    AuthObjectTypeEnum objectType =
        extractEnumValue(values, "objectType", element, annotationMirror);
    if (objectType == null) {
      processingEnv
          .getMessager()
          .printMessage(Diagnostic.Kind.ERROR, "objectType為空", element, annotationMirror);
      return;
    }
    AuthObjectValueEnum valueType =
        extractEnumValue(values, "valueType", element, annotationMirror);
    if (valueType == null) {
      processingEnv
          .getMessager()
          .printMessage(Diagnostic.Kind.ERROR, "valueType為空", element, annotationMirror);
      return;
    }
    if ((valueType.getValue() == AuthObjectValueEnum.COLLECTION_FIELD.getValue()
            || valueType.getValue() == AuthObjectValueEnum.OBJECT_COLLECTION_FIELD.getValue())
        && objectType.getValue() != AuthObjectTypeEnum.COMPANIES.getValue()) {
      processingEnv
          .getMessager()
          .printMessage(
              Diagnostic.Kind.ERROR,
              "當valueType為AuthObjectValueEnum.COLLECTION_FIELD或AuthObjectValueEnum.OBJECT_COLLECTION_FIELD時, objectType必須是AuthObjectTypeEnum.COMPANIES",
              element,
              annotationMirror);
    }
  }

  private void checkIndexConstraint(
      Element element,
      AnnotationMirror annotationMirror,
      Map<? extends ExecutableElement, ? extends AnnotationValue> values) {
    AnnotationValue indexValue = getAnnotationValue(values, "index");
    if (indexValue != null) {
      int indexVal = (Integer) indexValue.getValue();
      if (indexVal < 0) {
        processingEnv
            .getMessager()
            .printMessage(
                Diagnostic.Kind.ERROR, "index必須大於等於0,當前值: " + indexVal, element, annotationMirror);
      }
    }
  }

  private AnnotationMirror getAnnotationMirror(Element element, String annotationClassName) {
    for (AnnotationMirror mirror : element.getAnnotationMirrors()) {
      if (mirror.getAnnotationType().toString().equals(annotationClassName)) {
        return mirror;
      }
    }
    return null;
  }

  private <T> T extractEnumValue(
      Map<? extends ExecutableElement, ? extends AnnotationValue> values,
      String valueKey,
      Element element,
      AnnotationMirror annotationMirror) {
    AnnotationValue value = getAnnotationValue(values, valueKey);
    if (value == null) {
      return null;
    }
    String valueStr = value.toString();

    try {
      // 1. 剔除前綴 "java: ",trim去空格
      String enumFullStr = valueStr.replace("java: ", "").trim();
      // 2. 按最後一個"."拆分枚舉類名和常量名
      int lastDotIndex = enumFullStr.lastIndexOf(".");
      if (lastDotIndex == -1) {
        throw new IllegalArgumentException("枚舉字符串格式異常:" + valueStr);
      }
      // 枚舉類全限定名(如com.yourcompany.xxproject.api.annotation.enums.AuthObjectTypeEnum)
      String enumClassName = enumFullStr.substring(0, lastDotIndex);
      // 枚舉常量名(如COMPANIES)
      String enumConstantName = enumFullStr.substring(lastDotIndex + 1);
      // 步驟3:反射還原枚舉實例
      Class<?> enumClass = Class.forName(enumClassName);
      if (enumClass.isEnum()) {
        return (T) Enum.valueOf((Class<? extends Enum>) enumClass, enumConstantName);
      } else {
        processingEnv
            .getMessager()
            .printMessage(Diagnostic.Kind.ERROR, valueKey + "的值不是枚舉", element, annotationMirror);
      }
    } catch (Exception e) {
      processingEnv
          .getMessager()
          .printMessage(
              Diagnostic.Kind.ERROR,
              valueKey + "解析枚舉失敗,原始值:" + valueStr,
              element,
              annotationMirror);
    }
    return null;
  }

  private AnnotationValue getAnnotationValue(
      Map<? extends ExecutableElement, ? extends AnnotationValue> values, String key) {
    for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> entry :
        values.entrySet()) {
      if (entry.getKey().getSimpleName().toString().equals(key)) {
        return entry.getValue();
      }
    }
    return null;
  }
}
  1. 在切面所在的module的src/main/resources/META-INF/services路徑下,增加一個文件javax.annotation.processing.Processor,內容註解處理器的完整類路徑:
com.yourcompany.xxproject.annotation.processor.UserPermissionProcessor

如圖

image

驗證,寫一段違反規則的代碼,IDE build時提示:

image

繼續迭代...

  1. COMPANY和COMPANIES拼寫接近,如果用代碼自動補全,有可能寫錯。你可以將後者字面值改為MULTI_COMPANIES。

  2. 可以將對象鑑權和角色鑑權結合起來,判斷用户是否有某個角色的權限,也即在水平鑑權的基礎上增加垂直鑑權。當然你也可以用另一個註解來完成。

  3. 如果這個註解足夠通用,你可以將它們(包括UserPermissionManger)打成一個二方包,供其他應用使用。這時你需要研究下如何封裝遠程調用相關的代碼,這和你所用的微服務框架有關。實際上,我前司的鑑權平台就有這樣一個鑑權二方包。當你把它打成了二方包,就意味着使用者會日趨變多,調用量也會隨着業務不斷增長,不同的應用可能使用了這個包的不同版本,一定要在早期開始優化性能!

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.