实战:手写一个通用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);
}
}
}
十、总结与展望
通过这次改造,我们实现了:
目前这个注解已经在我们的核心业务上线,覆盖了200+接口。后续还可以扩展:
- 支持角色鉴权:增加@RolePermission注解
- 支持数据脱敏:结合注解实现字段级脱敏
- 支持操作审计:自动记录谁在什么时间操作了什么数据
- 做成Starter:封装成Spring Boot Starter,供其他项目复用
如果觉得文章有帮助,欢迎点赞收藏。有问题可以在评论区交流,我会尽量回复。

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