背景
目前我負責的一個公司內部Java應用,其Web層幾乎沒有進行水平鑑權,存在着一定的風險,比如A可以看到不屬於他的B公司的數據。最近公司進行滲透測試,將這個風險暴露出來,並將修復提上了議程。
由於Web層的接口很多,我希望能用一種較為通用易於接入的方式來完成這個工作。很容易就想到了通過註解方式進行水平鑑權。説來慚愧,我工作了十年多還沒有從0到1寫一個稍微複雜點的註解,正好利用這個機會進行學習和實踐。
我結合了一些現有代碼以及DeepSeek(元寶版)的建議,實現了相關功能。為了便於理解,本文在保留適用場景通用性的前提下,刪減無關的代碼。
鑑權場景
一個公司可以給一個或多個用户授權,一個用户可以被一個或多個公司授權。
用户只能查看被授權公司的數據。
架構
實現
註解定義
@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;
}
}
設計思路
目前的處理邏輯並不是第一版,和其他功能一樣也是從最基礎的功能開始,一點一點往上加的。簡述一下設計思路:
-
最常見的場景,使用方式最簡單。最常見的場景是鑑權所需參數(本例是公司id即companyId)直接出現在方法的形參表裏。這種情況只需要直接給方法打
@UserPermission註解。 -
儘量減少重複配置。如果一個Controller類的大部分入參形式是一樣的,那麼直接在Controller類上打註解。
-
預處理重複參數。多個comanyId鑑權時,在調用外部服務前,先做去重,可能可以減少服務提供方的系統開銷。當然,這裏的系統提供方的代碼也是我寫的,我會在提供方代碼裏也做一次去重。
-
直接放行的場景處理。如admin用户,不需要做任何權限配置就可以訪問,這塊邏輯可以放在切面,也可以放在UserPermissionManger
-
對象取值優先使用getter。Web層的Controller,入參對象一般都是pojo,直接調用getter效率更好。如果沒有pojo,那就需要使用反射方法來直接讀取屬性值。
使用示例
不同場景下,註解屬性字段的配置方式如下表:
| 場景名稱和實例 | objectType | valueType | index | paramName | isIgnore |
|---|---|---|---|---|---|
| 首個參數,且參數名稱為默認名稱
func(long companyId) |
默認(COMPANY) | 默認(RARE) | 默認(0) | 默認(companyId) | 默認
(false) |
| 首個參數,id列表
func(List |
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 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的屬性,現在註解是不是束手無策了?
有兩個選擇:
-
進一步擴展註解的適用場景,或者為這種場景編寫專屬的註解
-
直接編碼,先查Task,取companyId,手動調用userPermissionManger
我選用了方案二。方案一固然可以將更多的接口接入轉化為註解,但是也要根據實際情況,如果適用的接口並不多,新增的註解相關代碼也意味着額外的維護成本。
編譯期校驗
根據註解的使用實例,可以看到objectType和valueType是有一定的對應關係的,valueType=COLLECTION_FIELD或OBJECT_COLLECTION_FIELD時,objectType必然是COMPANIES。如果寫錯了,也沒測試出來,上線以後還要重新改,可不是什麼好事。
此外,index顯然是大於等於0的。如何將這些限制的檢查放到編譯期,從根源上防止配置錯誤呢?
方法是有的,而且有好幾種。但是有些方法需要增加三方庫依賴及對應的配置,我選擇了一個最簡單的方案,利用JDK8的原生能力就能完成,不過缺點是需要把註解相關代碼移到業務代碼之外的module,並且確保這個module比業務module先編譯。步驟如下:
- 首先,將你的註解相關代碼(切面代碼除外)移到業務層之外的module。不推薦放到對外打包的module,因此我選擇新建了一個。注意處理各個module的依賴,確保業務層依賴了這個module
- 編寫註解處理器代碼,對註解的配置值進行校驗
/**
* 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;
}
}
- 在切面所在的module的src/main/resources/META-INF/services路徑下,增加一個文件
javax.annotation.processing.Processor,內容註解處理器的完整類路徑:
com.yourcompany.xxproject.annotation.processor.UserPermissionProcessor
如圖
驗證,寫一段違反規則的代碼,IDE build時提示:
繼續迭代...
-
COMPANY和COMPANIES拼寫接近,如果用代碼自動補全,有可能寫錯。你可以將後者字面值改為MULTI_COMPANIES。
-
可以將對象鑑權和角色鑑權結合起來,判斷用户是否有某個角色的權限,也即在水平鑑權的基礎上增加垂直鑑權。當然你也可以用另一個註解來完成。
-
如果這個註解足夠通用,你可以將它們(包括UserPermissionManger)打成一個二方包,供其他應用使用。這時你需要研究下如何封裝遠程調用相關的代碼,這和你所用的微服務框架有關。實際上,我前司的鑑權平台就有這樣一個鑑權二方包。當你把它打成了二方包,就意味着使用者會日趨變多,調用量也會隨着業務不斷增長,不同的應用可能使用了這個包的不同版本,一定要在早期開始優化性能!