实战:手写通用 Web 层鉴权注解,解决水平权限漏洞
介绍如何通过自定义 Spring AOP 注解实现 Web 层水平权限校验。针对渗透测试发现的用户越权查看数据问题,设计了支持多种参数提取方式的 UserPermission 注解。通过编译期校验和运行时拦截,确保用户只能访问授权公司的数据。方案涵盖注解定义、AOP 切面逻辑、使用示例及性能优化建议,解决了重复代码多、维护难的问题,已在核心业务上线覆盖 200+ 接口。

介绍如何通过自定义 Spring AOP 注解实现 Web 层水平权限校验。针对渗透测试发现的用户越权查看数据问题,设计了支持多种参数提取方式的 UserPermission 注解。通过编译期校验和运行时拦截,确保用户只能访问授权公司的数据。方案涵盖注解定义、AOP 切面逻辑、使用示例及性能优化建议,解决了重复代码多、维护难的问题,已在核心业务上线覆盖 200+ 接口。

前段时间公司做渗透测试,我们系统暴露了一个典型的安全漏洞——水平权限漏洞。
简单来说,就是用户 A 可以看到不属于他所在公司的数据。比如:用户 A 登录系统后,修改 URL 中的公司 ID 参数,就能查看到 B 公司的业务数据。
这个问题在行业内其实很常见,核心原因是Web 层缺少水平鉴权。我们的系统运行好几年了,接口越来越多,但鉴权这块一直没好好做。
漏洞等级被定为高危,修复工作立刻提上议程。
面对几十个 Controller、几百个接口,我的修复方案必须满足:
我们的权限模型主要涉及以下实体关系:
规则很简单:
鉴权注解的核心流程:
@UserPermission 注解首先定义注解,通过属性来描述"要从哪里取鉴权信息":
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
}
封装调用外部权限平台的逻辑:
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)) {
();
}
List<Long> distinctIds = companyIds.stream().distinct().collect(Collectors.toList());
log.debug(, userName, distinctIds.size());
(distinctIds.size() == ) {
checkCompany(userName, distinctIds.get());
}
permissionClient.checkCompanies(userName, distinctIds);
checkResult(result);
}
{
(result == || !result.isSuccess() || result.getData() == ) {
log.error(, result);
();
}
BooleanUtils.isTrue(result.getData());
}
}
这是最关键的代码,负责拦截请求、提取鉴权值、调用权限服务:
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() {}
Object Throwable {
getAnnotation(joinPoint);
(annotation == || annotation.ignore()) {
joinPoint.proceed();
}
getCurrentUser();
(currentUser == ) {
();
}
(UserUtil.isAdmin(currentUser.getUserName())) {
log.debug();
joinPoint.proceed();
}
extractAuthValue(joinPoint, annotation);
checkUserPermission(
currentUser.getUserName(),
authValue,
annotation.objectType()
);
(!hasPermission) {
log.warn(, currentUser.getUserName(), authValue);
();
}
joinPoint.proceed();
}
UserPermission {
(MethodSignature) joinPoint.getSignature();
signature.getMethod();
Class<?> targetClass = signature.getDeclaringType();
method.getAnnotation(UserPermission.class);
(methodAnn != ) {
methodAnn;
}
targetClass.getAnnotation(UserPermission.class);
}
UserInfo {
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
(attrs == ) {
;
}
(UserInfo) attrs.getRequest().getAttribute();
}
Object {
(MethodSignature) joinPoint.getSignature();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
(paramNames == || paramNames.length == ) {
();
}
annotation.valueType();
(valueType == AuthValueType.RAW) {
extractRawParam(paramNames, args, annotation.paramName());
}
annotation.index();
(index < || index >= args.length) {
( + index);
}
args[index];
(target == ) {
( + index + );
}
(valueType) {
OBJECT_FIELD:
getFieldValue(target, annotation.paramName());
COLLECTION_FIELD:
getCollectionFieldValues(target, annotation.paramName());
NESTED_FIELD:
getNestedFieldValue(target, annotation.paramName());
COLLECTION_NESTED:
getCollectionNestedValues(target, annotation.paramName());
:
( + valueType);
}
}
Object {
( ; i < paramNames.length; i++) {
(paramName.equals(paramNames[i])) {
args[i];
}
}
( + paramName);
}
Object {
{
(fieldName, obj.getClass());
pd.getReadMethod();
(getter == ) {
( + fieldName + );
}
getter.invoke(obj);
} (Exception e) {
log.error(, fieldName, e);
( + fieldName);
}
}
List<Object> {
(!(obj Collection)) {
();
}
Collection<?> collection = (Collection<?>) obj;
collection.stream().map(item -> getFieldValue(item, fieldName)).collect(Collectors.toList());
}
Object {
String[] fields = fieldPath.split();
obj;
(String field : fields) {
(current == ) {
( + fieldPath);
}
current = getFieldValue(current, field);
}
current;
}
List<Object> {
String[] parts = fieldPath.split();
(parts.length != ) {
();
}
getFieldValue(obj, parts[]);
(!(collectionObj Collection)) {
(parts[] + );
}
Collection<?> collection = (Collection<?>) collectionObj;
collection.stream().map(item -> getFieldValue(item, parts[])).collect(Collectors.toList());
}
{
(objectType == AuthObjectType.COMPANY) {
convertToLong(authValue);
permissionManager.checkCompany(userName, companyId);
} {
List<Long> companyIds = convertToLongList(authValue);
permissionManager.checkCompanies(userName, companyIds);
}
}
Long {
(value Long) {
(Long) value;
}
(value Integer) {
((Integer) value).longValue();
}
(value String) {
Long.parseLong((String) value);
}
( + value);
}
List<Long> {
(value Collection) {
((Collection<?>) value).stream().map(::convertToLong).collect(Collectors.toList());
}
( + value);
}
}
看看实际项目中怎么用这个注解:
@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));
}
}
@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));
}
@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();
}
@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();
}
@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());
}
}
问题:编译后参数名变成 arg0、arg1,导致按名称取值失败。
解决:Maven 编译插件添加 -parameters 参数:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
问题:AOP 切面注入 UserPermissionManager,UserPermissionManager 又依赖 FeignClient,FeignClient 可能依赖 AOP,形成循环。
解决:使用 @Lazy 注解延迟加载:
@Aspect
@Component
@RequiredArgsConstructor
public class UserPermissionAspect {
@Lazy
private final UserPermissionManager permissionManager;
}
问题:批量接口传入的 companyIds 可能重复,重复调用权限平台浪费资源。
解决:在 UserPermissionManager 中先做去重:
List<Long> distinctIds = companyIds.stream().distinct().collect(Collectors.toList());
问题:切面中抛出异常,但业务方法的事务没有回滚。
解决:确保异常在事务切面之后抛出,调整切面顺序:
@Order(1) // 数字越小越先执行
public class UserPermissionAspect {
// ...
}
注解用起来很方便,但也很容易配错。比如 COLLECTION_FIELD 必须搭配 COMPANIES 使用,如果配成 COMPANY,运行时就会出错。
我们可以用注解处理器在编译期就发现这些问题:
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
(annotation.valueType() == AuthValueType.COLLECTION_NESTED && annotation.objectType() != AuthObjectType.COMPANIES) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
, element);
}
(annotation.index() < ) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
, element);
}
(annotation.valueType() == AuthValueType.RAW && (annotation.paramName() == || annotation.paramName().isEmpty())) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
, element);
}
}
}
在 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() {
// ...
}
随着接入的应用越来越多,有几个性能点需要注意:
权限关系相对稳定,可以加一层缓存:
@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));
}
}
对于 checkCompanies 接口,可以合并同一用户的多次请求:
// 使用异步批量处理
public CompletableFuture<Boolean> checkCompaniesAsync(String userName, List<Long> companyIds) {
// 合并请求,批量调用
}
反射获取属性值有一定开销,可以考虑缓存 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 注解
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online