跳到主要内容实战:手写通用 Web 层鉴权注解,解决水平权限漏洞 | 极客日志Javajava
实战:手写通用 Web 层鉴权注解,解决水平权限漏洞
综述由AI生成介绍如何通过自定义 Spring AOP 注解实现 Web 层水平权限校验。针对渗透测试发现的用户越权查看数据问题,设计了支持多种参数提取方式的 UserPermission 注解。通过编译期校验和运行时拦截,确保用户只能访问授权公司的数据。方案涵盖注解定义、AOP 切面逻辑、使用示例及性能优化建议,解决了重复代码多、维护难的问题,已在核心业务上线覆盖 200+ 接口。
墨染流年22 浏览 一、背景:一次渗透测试引发的改造
前段时间公司做渗透测试,我们系统暴露了一个典型的安全漏洞——水平权限漏洞。
简单来说,就是用户 A 可以看到不属于他所在公司的数据。比如:用户 A 登录系统后,修改 URL 中的公司 ID 参数,就能查看到 B 公司的业务数据。
这个问题在行业内其实很常见,核心原因是Web 层缺少水平鉴权。我们的系统运行好几年了,接口越来越多,但鉴权这块一直没好好做。
漏洞等级被定为高危,修复工作立刻提上议程。
二、需求分析:如何高效修复
面对几十个 Controller、几百个接口,我的修复方案必须满足:
- 接入简单:开发人员加个注解就能搞定,不用写重复代码
- 灵活通用:能处理各种奇葩的入参结构(直接参数、对象属性、集合嵌套等)
- 兼容老代码:不能影响现有逻辑,老的接口不加注解就保持原样
- 可扩展:后续可能增加角色鉴权、垂直鉴权等
三、业务模型:用户 - 公司授权关系
我们的权限模型主要涉及以下实体关系:
- User:用户表,包含用户名等基本信息。
- Company:公司表,包含公司 ID 和名称。
- UserCompany:授权关联表,连接用户与公司。
规则很简单:
- 一个用户可以被授权访问多个公司的数据
- 一个公司可以有多个授权用户
- 用户只能查看他有权限的公司的数据
四、整体架构设计
鉴权注解的核心流程:
- HTTP 请求进入 Spring MVC
- 检查是否有
@UserPermission 注解
- 若无注解,直接执行业务方法
- 若有注解,进入 AOP 切面
- 从 Request 获取用户信息
- 判断是否为 Admin,是则放行
- 从入参提取鉴权对象
- 调用权限平台接口
- 校验是否有权限,无则抛出异常
五、代码实现:一步一步来
5.1 注解定义
首先定义注解,通过属性来描述"要从哪里取鉴权信息":
package com.example.auth.annotation;
import java.lang.annotation.*;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface UserPermission {
AuthObjectType objectType() default AuthObjectType.COMPANY;
AuthValueType AuthValueType.RAW;
;
String ;
;
}
valueType
()
default
int
index
()
default
0
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,
OBJECT_FIELD,
COLLECTION_FIELD,
NESTED_FIELD,
COLLECTION_NESTED
}
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;
@Pointcut("execution(public * com.example.web.controller..*.*(..))")
public void controllerMethod() {}
@Around("controllerMethod()")
public Object checkPermission(ProceedingJoinPoint joinPoint) throws Throwable {
UserPermission annotation = getAnnotation(joinPoint);
if (annotation == null || annotation.ignore()) {
return joinPoint.proceed();
}
UserInfo currentUser = getCurrentUser();
if (currentUser == null) {
throw new BizException("获取用户信息失败");
}
if (UserUtil.isAdmin(currentUser.getUserName())) {
log.debug("Admin 用户放行");
return joinPoint.proceed();
}
Object authValue = extractAuthValue(joinPoint, annotation);
boolean hasPermission = checkUserPermission(
currentUser.getUserName(),
authValue,
annotation.objectType()
);
if (!hasPermission) {
log.warn("用户{}没有权限访问:{}", currentUser.getUserName(), authValue);
throw new BizException("您没有权限访问该数据");
}
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();
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:
return getFieldValue(target, annotation.paramName());
case COLLECTION_FIELD:
return getCollectionFieldValues(target, annotation.paramName());
case NESTED_FIELD:
return getNestedFieldValue(target, annotation.paramName());
case COLLECTION_NESTED:
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);
}
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());
}
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 {
@GetMapping("/list")
@UserPermission
public Result<List<AppInfo>> listApps(long 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) {
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) {
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) {
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,形成循环。
@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;
@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);
if (annotation.valueType() == AuthValueType.COLLECTION_FIELD && annotation.objectType() != AuthObjectType.COMPANIES) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
"当 valueType=COLLECTION_FIELD 时,objectType 必须是 COMPANIES", element);
}
if (annotation.valueType() == AuthValueType.COLLECTION_NESTED && annotation.objectType() != AuthObjectType.COMPANIES) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
"当 valueType=COLLECTION_NESTED 时,objectType 必须是 COMPANIES", element);
}
if (annotation.index() < 0) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
"index 不能小于 0", element);
}
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
@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);
}
}
}
十、总结与展望
- 统一鉴权:所有接口都用同一套注解,规范统一
- 接入简单:开发人员只需要加注解,不用写重复代码
- 灵活通用:支持 5 种常见的取值场景,覆盖 95% 以上的接口
- 安全可靠:编译期校验 + 运行时检查,双重保障
目前这个注解已经在我们的核心业务上线,覆盖了 200+ 接口。后续还可以扩展:
- 支持角色鉴权:增加
@RolePermission 注解
- 支持数据脱敏:结合注解实现字段级脱敏
- 支持操作审计:自动记录谁在什么时间操作了什么数据
- 做成 Starter:封装成 Spring Boot Starter,供其他项目复用
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online