水平权限漏洞(Horizontal Privilege Escalation)是Web应用中常见的安全风险之一。简单来说,就是当用户A通过身份验证后,能够越权访问本应只有用户B才能查看或操作的数据资源。这种漏洞在电商订单系统、社交网络私信功能、企业OA文档管理等场景中尤为常见。
去年我在重构公司CRM系统时就踩过这个坑。当时有个查询客户详情的接口,开发同学只做了基础的登录校验,却忘了验证当前用户是否真的拥有该客户数据的访问权限。结果测试人员轻松用普通销售账号看到了CEO的客户资料——这要是发生在生产环境,后果不堪设想。
传统解决方案是在每个Service方法里硬编码权限校验逻辑,比如:
java复制public CustomerDetail getCustomerDetail(Long customerId) {
// 先查当前登录用户
User currentUser = getCurrentUser();
// 再验证客户归属
if(!customerService.isOwner(currentUser.getId(), customerId)){
throw new PermissionDeniedException();
}
// 真实业务逻辑...
}
这种写法存在三个明显问题:
我们采用AOP+注解的方式实现解耦,整体架构分为三层:
code复制┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ @AuthCheck │───▶│ AuthAspect │───▶│ AuthService │
└─────────────────┘ └─────────────────┘ └─────────────────┘
▲ ▲ ▲
│ │ │
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Controller │ │ Spring AOP │ │ 数据访问层 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
创建自定义注解@AuthCheck,支持以下配置项:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {
// 资源类型(如"customer", "order")
String resourceType();
// 资源ID参数名(默认为"id")
String idParam() default "id";
// 权限验证策略(默认OWNER校验)
AuthStrategy strategy() default AuthStrategy.OWNER;
// 支持SPEL表达式
String condition() default "";
}
策略枚举定义:
java复制public enum AuthStrategy {
OWNER, // 资源所有者校验
DEPARTMENT, // 同部门校验
ROLE, // 角色权限校验
CUSTOM // 自定义SPEL校验
}
java复制@Aspect
@Component
@RequiredArgsConstructor
public class AuthAspect {
private final AuthService authService;
@Around("@annotation(authCheck)")
public Object checkAuth(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
// 1. 获取方法参数
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Object[] args = joinPoint.getArgs();
// 2. 解析资源ID
Long resourceId = resolveResourceId(signature, args, authCheck.idParam());
// 3. 执行权限校验
switch(authCheck.strategy()) {
case OWNER:
authService.checkOwner(resourceId, authCheck.resourceType());
break;
case DEPARTMENT:
authService.checkSameDepartment(resourceId, authCheck.resourceType());
break;
case ROLE:
authService.checkRole(authCheck.condition());
break;
case CUSTOM:
authService.checkBySpel(joinPoint.getTarget(), args, authCheck.condition());
break;
}
// 4. 通过后执行原方法
return joinPoint.proceed();
}
private Long resolveResourceId(MethodSignature signature, Object[] args, String idParam) {
// 参数名解析实现...
}
}
java复制@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final UserDao userDao;
private final CustomerDao customerDao;
@Override
public void checkOwner(Long resourceId, String resourceType) {
Long currentUserId = SecurityUtils.getCurrentUserId();
switch(resourceType) {
case "customer":
if(!customerDao.existsByIdAndOwnerId(resourceId, currentUserId)) {
throw new PermissionDeniedException("无权访问该客户信息");
}
break;
case "order":
// 订单校验逻辑...
break;
default:
throw new IllegalArgumentException("未知资源类型");
}
}
// 其他策略实现...
}
java复制@GetMapping("/customers/{id}")
@AuthCheck(resourceType = "customer", idParam = "id")
public CustomerDetail getCustomer(@PathVariable Long id) {
// 无需手动校验权限
return customerService.getDetail(id);
}
支持SPEL表达式实现动态校验:
java复制@PostMapping("/projects/{projectId}/docs")
@AuthCheck(
resourceType = "project",
strategy = AuthStrategy.CUSTOM,
condition = "#projectId == authentication.principal.projectId"
)
public void uploadDocument(@PathVariable Long projectId, @RequestBody Document doc) {
// 上传文档逻辑...
}
java复制@AuthCheck(resourceType = "customer", idParam = "ids")
public List<Customer> batchQuery(@RequestBody List<Long> ids) {
// 切面内会自动对ids列表做批量校验
}
java复制public void checkOwner(Long resourceId, String resourceType) {
String cacheKey = "auth:" + resourceType + ":" + resourceId;
Boolean cached = redisTemplate.opsForValue().get(cacheKey);
if(cached != null) {
if(!cached) throw new PermissionDeniedException();
return;
}
// 真实校验逻辑...
redisTemplate.opsForValue().set(cacheKey, checkResult, 5, TimeUnit.MINUTES);
}
参数解析失败:
IllegalArgumentException: Can't resolve parameter ididParam值与接口参数名一致resolveResourceId方法中添加日志SPEL表达式不生效:
#projectId需对应参数名)SpelExpressionParser调试输出AOP不生效:
java复制@AuthCheck(resourceType = "document", strategy = AuthStrategy.OWNER_OR_ADMIN)
java复制@AfterReturning("@annotation(authCheck)")
public void logAuthAccess(JoinPoint joinPoint, AuthCheck authCheck) {
auditLogService.logAccess(
authCheck.resourceType(),
resolveResourceId(joinPoint, authCheck),
SecurityUtils.getCurrentUserId()
);
}
properties复制# auth-policy.properties
customer.read=OWNER,DEPARTMENT_ADMIN
customer.write=OWNER
order.cancel=OWNER,BUSINESS_ADMIN
在实现过程中,我发现对于复杂的业务系统,建议将权限策略配置化,通过数据库或配置文件管理,而不是硬编码在注解中。同时要注意避免过度设计——对于简单的CRUD应用,传统的角色鉴权可能就足够了。