实战:手写一个通用Web层鉴权注解,解决水平权限漏洞
实战:手写一个通用Web层鉴权注解,解决水平权限漏洞
🌺The Begin🌺点点关注,收藏不迷路🌺 |
一、背景:一次渗透测试引发的改造
前段时间公司做渗透测试,我们系统暴露了一个典型的安全漏洞——水平权限漏洞。
简单来说,就是用户A可以看到不属于他所在公司的数据。比如:用户A登录系统后,修改URL中的公司ID参数,就能查看到B公司的业务数据。
这个问题在行业内其实很常见,核心原因是Web层缺少水平鉴权。我们的系统运行好几年了,接口越来越多,但鉴权这块一直没好好做。
漏洞等级被定为高危,修复工作立刻提上议程。
二、需求分析:如何高效修复
面对几十个Controller、几百个接口,我的修复方案必须满足:
- 接入简单:开发人员加个注解就能搞定,不用写重复代码
- 灵活通用:能处理各种奇葩的入参结构(直接参数、对象属性、集合嵌套等)
- 兼容老代码:不能影响现有逻辑,老的接口不加注解就保持原样
- 可扩展:后续可能增加角色鉴权、垂直鉴权等
三、业务模型:用户-公司授权关系
先看下我们的权限模型:
has
has
User
string
userName
PK
string
nickName
UserCompany
string
userName
FK
long
companyId
FK
Company
long
companyId
PK
string
companyName
规则很简单:
- 一个用户可以被授权访问多个公司的数据
- 一个公司可以有多个授权用户
- 用户只能查看他有权限的公司的数据
四、整体架构设计
鉴权注解的核心流程:
权限平台
请求处理流程
无
有
是
否
是
否
HTTP请求
Spring MVC
是否有@UserPermission?
直接执行业务方法
进入AOP切面
从Request获取用户信息
是否是Admin?
从入参提取鉴权对象
调用权限平台接口
是否有权限?
抛出权限异常
用户-公司关系服务
五、代码实现:一步一步来
5.1 注解定义
首先定义注解,通过属性来描述"要从哪里取鉴权信息":
packagecom.example.auth.annotation;importjava.lang.annotation.*;/** * 用户权限注解 * 用在Controller方法或类上,进行水平权限校验 */@Target({ElementType.TYPE,ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)@Documentedpublic@interfaceUserPermission{/** * 鉴权对象类型:单个公司还是多个公司 */AuthObjectTypeobjectType()defaultAuthObjectType.COMPANY;/** * 鉴权值类型:描述如何从入参中提取值 */AuthValueTypevalueType()defaultAuthValueType.RAW;/** * 参数索引,当valueType不是RAW时,指定从第几个参数取值 */intindex()default0;/** * 参数名称,支持多级,如 "companyInfo.companyId" */StringparamName()default"companyId";/** * 是否忽略鉴权,用于覆盖类上的注解 */booleanignore()defaultfalse;}两个枚举的定义:
packagecom.example.auth.annotation;/** * 鉴权对象类型 */publicenumAuthObjectType{COMPANY,// 单个公司COMPANIES// 多个公司}packagecom.example.auth.annotation;/** * 鉴权值类型:定义如何从入参中提取值 */publicenumAuthValueType{RAW,// 原始参数,直接就是companyId或companyIdsOBJECT_FIELD,// 对象的属性,如 bo.companyIdCOLLECTION_FIELD,// 集合元素的属性,如 List<Bo> 取 Bo.companyIdNESTED_FIELD,// 嵌套属性,如 bo.companyInfo.companyIdCOLLECTION_NESTED// 集合中的嵌套属性,如 bo.companyList.companyId}5.2 权限管理服务
封装调用外部权限平台的逻辑:
packagecom.example.auth.manager;importcom.example.auth.client.UserPermissionFeignClient;importcom.example.common.exception.BizException;importcom.example.common.util.UserUtil;importlombok.RequiredArgsConstructor;importlombok.extern.slf4j.Slf4j;importorg.apache.commons.collections4.CollectionUtils;importorg.apache.commons.lang3.BooleanUtils;importorg.springframework.stereotype.Component;importjava.util.List;importjava.util.stream.Collectors;/** * 用户权限管理器 * 封装调用权限平台的逻辑 */@Slf4j@Component@RequiredArgsConstructorpublicclassUserPermissionManager{privatefinalUserPermissionFeignClient permissionClient;/** * 校验用户是否有指定公司的权限 */publicbooleancheckCompany(String userName,Long companyId){if(companyId ==null){thrownewBizException("公司ID不能为空");} log.debug("校验用户{}对公司{}的权限", userName, companyId);var result = permissionClient.checkCompany(userName, companyId);returncheckResult(result);}/** * 校验用户是否有所有指定公司的权限 */publicbooleancheckCompanies(String userName,List<Long> companyIds){if(CollectionUtils.isEmpty(companyIds)){thrownewBizException("公司ID列表不能为空");}// 先去重,减少调用次数List<Long> distinctIds = companyIds.stream().distinct().collect(Collectors.toList()); log.debug("校验用户{}对{}个公司的权限", userName, distinctIds.size());if(distinctIds.size()==1){// 单个公司走单条接口returncheckCompany(userName, distinctIds.get(0));}var result = permissionClient.checkCompanies(userName, distinctIds);returncheckResult(result);}privatebooleancheckResult(Result<Boolean> result){if(result ==null||!result.isSuccess()|| result.getData()==null){ log.error("调用权限平台失败: {}", result);thrownewBizException("权限校验服务异常");}returnBooleanUtils.isTrue(result.getData());}}5.3 AOP切面:核心逻辑
这是最关键的代码,负责拦截请求、提取鉴权值、调用权限服务:
packagecom.example.auth.aspect;importcom.example.auth.annotation.UserPermission;importcom.example.auth.annotation.AuthObjectType;importcom.example.auth.annotation.AuthValueType;importcom.example.auth.manager.UserPermissionManager;importcom.example.auth.model.UserInfo;importcom.example.common.exception.BizException;importcom.example.common.util.UserUtil;importlombok.RequiredArgsConstructor;importlombok.extern.slf4j.Slf4j;importorg.aspectj.lang.ProceedingJoinPoint;importorg.aspectj.lang.annotation.Around;importorg.aspectj.lang.annotation.Aspect;importorg.aspectj.lang.annotation.Pointcut;importorg.aspectj.lang.reflect.MethodSignature;importorg.springframework.stereotype.Component;importorg.springframework.web.context.request.RequestContextHolder;importorg.springframework.web.context.request.ServletRequestAttributes;importjava.beans.PropertyDescriptor;importjava.lang.reflect.Method;importjava.util.Collection;importjava.util.List;importjava.util.stream.Collectors;/** * 用户权限切面 */@Slf4j@Aspect@Component@RequiredArgsConstructorpublicclassUserPermissionAspect{privatefinalUserPermissionManager permissionManager;/** * 切点:所有Controller下的public方法 */@Pointcut("execution(public * com.example.web.controller..*.*(..))")publicvoidcontrollerMethod(){}@Around("controllerMethod()")publicObjectcheckPermission(ProceedingJoinPoint joinPoint)throwsThrowable{// 1. 获取注解UserPermission annotation =getAnnotation(joinPoint);if(annotation ==null|| annotation.ignore()){// 没注解或忽略鉴权,直接放行return joinPoint.proceed();}// 2. 获取当前用户UserInfo currentUser =getCurrentUser();if(currentUser ==null){thrownewBizException("获取用户信息失败");}// 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);thrownewBizException("您没有权限访问该数据");}// 6. 放行return joinPoint.proceed();}/** * 获取方法上的注解,优先取方法级,没有则取类级 */privateUserPermissiongetAnnotation(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);}/** * 从请求中获取当前用户 */privateUserInfogetCurrentUser(){ServletRequestAttributes attrs =(ServletRequestAttributes)RequestContextHolder.getRequestAttributes();if(attrs ==null){returnnull;}return(UserInfo) attrs.getRequest().getAttribute("userInfo");}/** * 从方法参数中提取鉴权值(核心方法) */privateObjectextractAuthValue(ProceedingJoinPoint joinPoint,UserPermission annotation){MethodSignature signature =(MethodSignature) joinPoint.getSignature();String[] paramNames = signature.getParameterNames();Object[] args = joinPoint.getArgs();if(paramNames ==null|| paramNames.length ==0){thrownewBizException("方法没有参数,无法提取鉴权值");}AuthValueType valueType = annotation.valueType();// 场景1:原始参数if(valueType ==AuthValueType.RAW){returnextractRawParam(paramNames, args, annotation.paramName());}// 其他场景:需要从对象中取值int index = annotation.index();if(index <0|| index >= args.length){thrownewBizException("参数索引越界: "+ index);}Object target = args[index];if(target ==null){thrownewBizException("第"+ index +"个参数为null");}switch(valueType){caseOBJECT_FIELD:// 场景2:对象的属性,如 bo.companyIdreturngetFieldValue(target, annotation.paramName());caseCOLLECTION_FIELD:// 场景3:集合元素的属性,如 List<Bo> 取 Bo.companyIdreturngetCollectionFieldValues(target, annotation.paramName());caseNESTED_FIELD:// 场景4:嵌套属性,如 bo.companyInfo.companyIdreturngetNestedFieldValue(target, annotation.paramName());caseCOLLECTION_NESTED:// 场景5:集合中的嵌套属性,如 bo.companyList.companyIdreturngetCollectionNestedValues(target, annotation.paramName());default:thrownewBizException("不支持的取值类型: "+ valueType);}}/** * 提取原始参数 */privateObjectextractRawParam(String[] paramNames,Object[] args,String paramName){for(int i =0; i < paramNames.length; i++){if(paramName.equals(paramNames[i])){return args[i];}}thrownewBizException("未找到参数: "+ paramName);}/** * 通过反射获取对象属性值(使用getter方法) */privateObjectgetFieldValue(Object obj,String fieldName){try{PropertyDescriptor pd =newPropertyDescriptor(fieldName, obj.getClass());Method getter = pd.getReadMethod();if(getter ==null){thrownewBizException("属性 "+ fieldName +" 没有getter方法");}return getter.invoke(obj);}catch(Exception e){ log.error("获取属性值失败: {}", fieldName, e);thrownewBizException("解析参数失败: "+ fieldName);}}/** * 获取集合元素的属性值列表 */privateList<Object>getCollectionFieldValues(Object obj,String fieldName){if(!(obj instanceofCollection)){thrownewBizException("参数不是Collection类型");}Collection<?> collection =(Collection<?>) obj;return collection.stream().map(item ->getFieldValue(item, fieldName)).collect(Collectors.toList());}/** * 获取嵌套属性值,如 obj.field1.field2 */privateObjectgetNestedFieldValue(Object obj,String fieldPath){String[] fields = fieldPath.split("\\.");Object current = obj;for(String field : fields){if(current ==null){thrownewBizException("嵌套属性路径中有null值: "+ fieldPath);} current =getFieldValue(current, field);}return current;}/** * 获取集合中的嵌套属性值 */privateList<Object>getCollectionNestedValues(Object obj,String fieldPath){String[] parts = fieldPath.split("\\.");if(parts.length !=2){thrownewBizException("COLLECTION_NESTED类型需要两级路径,如: companyList.companyId");}// 第一级:获取集合属性Object collectionObj =getFieldValue(obj, parts[0]);if(!(collectionObj instanceofCollection)){thrownewBizException(parts[0]+"不是Collection类型");}// 第二级:遍历集合,获取每个元素的属性Collection<?> collection =(Collection<?>) collectionObj;return collection.stream().map(item ->getFieldValue(item, parts[1])).collect(Collectors.toList());}/** * 执行权限校验 */privatebooleancheckUserPermission(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);}}privateLongconvertToLong(Object value){if(value instanceofLong){return(Long) value;}if(value instanceofInteger){return((Integer) value).longValue();}if(value instanceofString){returnLong.parseLong((String) value);}thrownewBizException("无法转换为Long类型: "+ value);}@SuppressWarnings("unchecked")privateList<Long>convertToLongList(Object value){if(value instanceofCollection){return((Collection<?>) value).stream().map(this::convertToLong).collect(Collectors.toList());}thrownewBizException("无法转换为Long列表: "+ value);}}六、使用示例
看看实际项目中怎么用这个注解:
6.1 场景1:最简单的用法
@RestController@RequestMapping("/api/app")publicclassAppController{/** * 直接参数:companyId就在参数列表里 */@GetMapping("/list")@UserPermissionpublicResult<List<AppInfo>>listApps(long companyId){// 直接使用companyId,注解自动取值returnResult.success(appService.listByCompany(companyId));}}6.2 场景2:对象属性
@DatapublicclassAppQueryRequest{privateLong companyId;privateString appName;privateInteger pageNum;privateInteger pageSize;}@PostMapping("/query")@UserPermission( valueType =AuthValueType.OBJECT_FIELD, paramName ="companyId")publicResult<PageInfo<AppInfo>>queryApps(@RequestBodyAppQueryRequest request){// 从request.companyId取值returnResult.success(appService.queryPage(request));}6.3 场景3:批量操作
@PostMapping("/batch/delete")@UserPermission( objectType =AuthObjectType.COMPANIES, valueType =AuthValueType.COLLECTION_FIELD, paramName ="companyId")publicResult<Void>batchDelete(@RequestBodyList<AppInfo> apps){// 从每个AppInfo对象中提取companyId,组成列表后校验// 确保用户对这些companyId都有权限 appService.batchDelete(apps);returnResult.success();}6.4 场景4:嵌套属性
@DatapublicclassComplexRequest{privateCompanyInfo companyInfo;@DatapublicstaticclassCompanyInfo{privateLong companyId;}}@PostMapping("/complex")@UserPermission( valueType =AuthValueType.NESTED_FIELD, paramName ="companyInfo.companyId")publicResult<Object>complexOperation(@RequestBodyComplexRequest request){// 从request.companyInfo.companyId取值returnResult.success();}6.5 场景5:类级别默认配置
@RestController@RequestMapping("/api/user")@UserPermission(valueType =AuthValueType.OBJECT_FIELD, paramName ="companyId")publicclassUserController{/** * 继承类上的注解 */@GetMapping("/list")publicResult<List<UserVO>>listUsers(@RequestParamLong companyId){returnResult.success(userService.listByCompany(companyId));}/** * 覆盖类上的注解 */@PostMapping("/batch/query")@UserPermission( objectType =AuthObjectType.COMPANIES, valueType =AuthValueType.COLLECTION_FIELD, paramName ="companyId")publicResult<List<UserVO>>batchQuery(@RequestBodyList<CompanyQuery> queries){returnResult.success(userService.batchQuery(queries));}/** * 忽略鉴权 */@GetMapping("/public/info")@UserPermission(ignore =true)publicResult<PublicInfo>getPublicInfo(){returnResult.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@RequiredArgsConstructorpublicclassUserPermissionAspect{@LazyprivatefinalUserPermissionManager permissionManager;}坑3:集合参数去重
问题:批量接口传入的companyIds可能重复,重复调用权限平台浪费资源。
解决:在UserPermissionManager中先做去重:
List<Long> distinctIds = companyIds.stream().distinct().collect(Collectors.toList());坑4:事务失效
问题:切面中抛出异常,但业务方法的事务没有回滚。
解决:确保异常在事务切面之后抛出,调整切面顺序:
@Order(1)// 数字越小越先执行publicclassUserPermissionAspect{// ...}八、编译期校验:把问题扼杀在摇篮里
注解用起来很方便,但也很容易配错。比如COLLECTION_FIELD必须搭配COMPANIES使用,如果配成COMPANY,运行时就会出错。
我们可以用注解处理器在编译期就发现这些问题:
8.1 创建注解处理器
packagecom.example.auth.processor;importcom.example.auth.annotation.UserPermission;importcom.example.auth.annotation.AuthObjectType;importcom.example.auth.annotation.AuthValueType;importjavax.annotation.processing.*;importjavax.lang.model.SourceVersion;importjavax.lang.model.element.*;importjavax.lang.model.type.TypeMirror;importjavax.tools.Diagnostic;importjava.util.Set;/** * UserPermission注解处理器 * 编译期校验注解配置是否正确 */@SupportedAnnotationTypes("com.example.auth.annotation.UserPermission")@SupportedSourceVersion(SourceVersion.RELEASE_8)publicclassUserPermissionProcessorextendsAbstractProcessor{@Overridepublicbooleanprocess(Set<?extendsTypeElement> annotations,RoundEnvironment roundEnv){for(Element element : roundEnv.getElementsAnnotatedWith(UserPermission.class)){checkAnnotation(element);}returntrue;}privatevoidcheckAnnotation(Element element){UserPermission annotation = element.getAnnotation(UserPermission.class);// 规则1:COLLECTION_FIELD必须搭配COMPANIESif(annotation.valueType()==AuthValueType.COLLECTION_FIELD&& annotation.objectType()!=AuthObjectType.COMPANIES){ processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,"当valueType=COLLECTION_FIELD时,objectType必须是COMPANIES", element );}// 规则2:COLLECTION_NESTED必须搭配COMPANIESif(annotation.valueType()==AuthValueType.COLLECTION_NESTED&& annotation.objectType()!=AuthObjectType.COMPANIES){ processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,"当valueType=COLLECTION_NESTED时,objectType必须是COMPANIES", element );}// 规则3:index不能小于0if(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)publicResult<?>badMethod(){// ...}九、性能优化建议
随着接入的应用越来越多,有几个性能点需要注意:
9.1 缓存权限结果
权限关系相对稳定,可以加一层缓存:
@ComponentpublicclassCachedUserPermissionManagerextendsUserPermissionManager{privatefinalCache<String,Boolean> permissionCache =Caffeine.newBuilder().expireAfterWrite(5,TimeUnit.MINUTES).maximumSize(10000).build();@OverridepublicbooleancheckCompany(String userName,Long companyId){String key = userName +":"+ companyId;return permissionCache.get(key, k ->super.checkCompany(userName, companyId));}}9.2 批量接口合并请求
对于checkCompanies接口,可以合并同一用户的多次请求:
// 使用异步批量处理publicCompletableFuture<Boolean>checkCompaniesAsync(String userName,List<Long> companyIds){// 合并请求,批量调用}9.3 反射优化
反射获取属性值有一定开销,可以考虑缓存PropertyDescriptor:
@ComponentpublicclassFieldReader{privatefinalConcurrentMap<String,PropertyDescriptor> cache =newConcurrentHashMap<>();publicObjectreadField(Object obj,String fieldName){Class<?> clazz = obj.getClass();String key = clazz.getName()+"#"+ fieldName;PropertyDescriptor pd = cache.computeIfAbsent(key, k ->{try{returnnewPropertyDescriptor(fieldName, clazz);}catch(IntrospectionException e){thrownewRuntimeException(e);}});try{return pd.getReadMethod().invoke(obj);}catch(Exception e){thrownewRuntimeException(e);}}}十、总结与展望
通过这次改造,我们实现了:
- ✅ 统一鉴权:所有接口都用同一套注解,规范统一
- ✅ 接入简单:开发人员只需要加注解,不用写重复代码
- ✅ 灵活通用:支持5种常见的取值场景,覆盖95%以上的接口
- ✅ 安全可靠:编译期校验+运行时检查,双重保障
目前这个注解已经在我们的核心业务上线,覆盖了200+接口。后续还可以扩展:
- 支持角色鉴权:增加
@RolePermission注解 - 支持数据脱敏:结合注解实现字段级脱敏
- 支持操作审计:自动记录谁在什么时间操作了什么数据
- 做成Starter:封装成Spring Boot Starter,供其他项目复用
如果觉得文章有帮助,欢迎点赞收藏。有问题可以在评论区交流,我会尽量回复。
🌺The End🌺点点关注,收藏不迷路🌺 |