在 Spring Boot 项目中,接口权限控制是很常见的需求。
常规做法一般是:
定义一个权限校验注解;
在接口方法上添加注解;
使用 AOP 拦截接口调用;
在切面中判断当前用户是否有权限;
有权限则放行,没有权限则拒绝访问。
这种方式适合简单权限判断。
但在实际业务中,权限规则往往不是单一的。
例如:
登录后才允许访问;
只要有任意角色即可访问;
必须拥有某个权限编码;
必须拥有某个角色;
必须同时拥有多个角色;
只有超级管理员可以访问;
某些接口允许所有人访问;
某些接口只允许指定时间段访问。
如果每种场景都单独写一个注解或在切面中写大量 if...else,后期会很难维护。
这时可以引入 SpEL。
通过 SpEL,可以把复杂权限规则写成表达式,放到注解中,由切面统一解析执行。
一、为什么引入 SpEL
普通权限注解可能长这样:
@PreAuth("MENU.QUERY")切面中根据注解值判断用户是否拥有权限。
这种方式只能处理单一权限编码。
一旦需求变复杂,例如:
管理员可以访问
或者同时拥有 角色A 和 角色B 才能访问
或者指定时间段内才能访问原来的注解设计就不够灵活。
使用 SpEL 后,可以把权限规则写成:
@PreAuth("hasPermission('MENU.QUERY')")或者:
@PreAuth("hasAllRole('管理员', '总工程师')")再比如:
@PreAuth("hasTimeAuth(9, 18)")这样权限判断逻辑可以统一放在一个权限函数类中。
新增一种权限规则时,只需要增加一个函数,不需要修改注解结构,也不需要在切面里堆大量判断逻辑。
二、什么是 SpEL
SpEL 全称是 Spring Expression Language,也就是 Spring 表达式语言。
它可以在运行时解析字符串表达式,并执行对应逻辑。
例如下面这个表达式:
hasRole('admin')可以被 SpEL 解析成对某个对象上 hasRole 方法的调用。
再比如:
hasAllRole('admin', 'manager')可以被解析成:
authFun.hasAllRole("admin", "manager")这就是 SpEL 在权限控制中有用的地方:
注解中写表达式,切面中解析表达式,权限函数中实现具体判断逻辑。
三、整体实现思路
整体流程如下:
请求进入 Controller
↓
AOP 拦截带有 @PreAuth 的方法或类
↓
读取注解中的 SpEL 表达式
↓
构造 SpEL 上下文
↓
注册权限函数对象 AuthFun
↓
执行表达式
↓
返回 true 放行,false 拒绝核心组件包括:
@PreAuth:权限注解;AuthFun:权限函数类;PreAuthAspect:权限切面;SpelExpressionParser:SpEL 表达式解析器;StandardEvaluationContext:表达式上下文。
四、定义权限注解
先定义一个自定义注解。
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PreAuth {
/**
* SpEL 权限表达式
*/
String value();
}这里支持作用在方法和类上:
@Target({ElementType.METHOD, ElementType.TYPE})如果标在方法上,只对当前方法生效。
如果标在类上,可以对整个 Controller 或 Service 生效。
示例:
@PreAuth("hasAuth()")
@RestController
@RequestMapping("/user")
public class UserController {
}也可以标在具体方法上:
@PreAuth("hasPermission('USER.QUERY')")
@GetMapping("/list")
public List<UserVO> list() {
return userService.list();
}五、表达式设计示例
注解中的表达式可以设计成下面这些形式:
@PreAuth("permitAll()")表示允许所有请求访问。
@PreAuth("hasAuth()")表示登录后才可以访问。
@PreAuth("hasPermission('MENU.QUERY')")表示拥有指定权限编码才可以访问。
@PreAuth("hasRole('管理员')")
表示拥有管理员角色才可以访问。
@PreAuth("hasAllRole('管理员', '总工程师')")表示必须同时拥有多个角色。
@PreAuth("hasAnyRole('管理员', '运营')")表示拥有其中任意一个角色即可访问。
@PreAuth("hasTimeAuth(9, 18)")表示只允许 9 点到 18 点之间访问。
这种写法的好处是,权限规则直接体现在注解上,阅读接口时比较直观。
六、定义权限函数类 AuthFun
AuthFun 用来承载具体的权限判断逻辑。
SpEL 表达式中能调用的方法,都定义在这个类中。
import java.time.LocalTime;
import java.util.Arrays;
import java.util.Set;
public class AuthFun {
/**
* 放行所有请求
*/
public boolean permitAll() {
return true;
}
/**
* 拒绝所有请求
*/
public boolean denyAll() {
return false;
}
/**
* 判断是否已登录
*/
public boolean hasAuth() {
return AuthContext.getUser() != null;
}
/**
* 判断是否拥有指定权限
*/
public boolean hasPermission(String permission) {
LoginUser user = AuthContext.getUser();
if (user == null || user.getPermissions() == null) {
return false;
}
return user.getPermissions().contains(permission);
}
/**
* 判断是否拥有某个角色
*/
public boolean hasRole(String role) {
return hasAnyRole(role);
}
/**
* 判断是否拥有任意一个角色
*/
public boolean hasAnyRole(String... roles) {
LoginUser user = AuthContext.getUser();
if (user == null || user.getRoles() == null) {
return false;
}
Set<String> userRoles = user.getRoles();
return Arrays.stream(roles)
.anyMatch(userRoles::contains);
}
/**
* 判断是否同时拥有所有角色
*/
public boolean hasAllRole(String... roles) {
LoginUser user = AuthContext.getUser();
if (user == null || user.getRoles() == null) {
return false;
}
Set<String> userRoles = user.getRoles();
return Arrays.stream(roles)
.allMatch(userRoles::contains);
}
/**
* 判断当前时间是否在允许访问区间内
*/
public boolean hasTimeAuth(Integer startHour, Integer endHour) {
int hour = LocalTime.now().getHour();
return hour >= startHour && hour <= endHour;
}
}
其中 AuthContext.getUser() 表示获取当前登录用户。
实际项目中可以替换成自己的登录上下文,例如:
SecurityContextHolder
TokenContext
LoginUserHolder
AuthUtil七、定义登录用户对象
为了说明权限判断逻辑,可以定义一个简单的登录用户对象。
import java.util.Set;
public class LoginUser {
private Long userId;
private String username;
private Set<String> roles;
private Set<String> permissions;
public Long getUserId() {
return userId;
}
public String getUsername() {
return username;
}
public Set<String> getRoles() {
return roles;
}
public Set<String> getPermissions() {
return permissions;
}
}
实际项目中,这个对象通常来自 Token、Session 或 Spring Security 上下文。
八、定义认证上下文
示例中用一个简单的上下文类表示当前登录用户。
public class AuthContext {
private static final ThreadLocal<LoginUser> USER_HOLDER = new ThreadLocal<>();
public static void setUser(LoginUser user) {
USER_HOLDER.set(user);
}
public static LoginUser getUser() {
return USER_HOLDER.get();
}
public static void clear() {
USER_HOLDER.remove();
}
}请求进入时可以在过滤器或拦截器中解析 Token,然后放入上下文。
请求结束后一定要清理:
AuthContext.clear();否则在线程复用场景下可能出现用户上下文残留。
九、定义权限切面
接下来定义 AOP 切面。
切面需要拦截:
方法上的
@PreAuth;类上的
@PreAuth。
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.context.ApplicationContext;
import org.springframework.context.expression.BeanFactoryResolver;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
@Aspect
@Component
public class PreAuthAspect {
private static final ExpressionParser EXPRESSION_PARSER = new SpelExpressionParser();
private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER =
new DefaultParameterNameDiscoverer();
private final ApplicationContext applicationContext;
public PreAuthAspect(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Around("@annotation(PreAuth) || @within(PreAuth)")
public Object preAuth(ProceedingJoinPoint point) throws Throwable {
if (handleAuth(point)) {
return point.proceed();
}
throw new RuntimeException("请求无权限访问");
}
private boolean handleAuth(ProceedingJoinPoint point) {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
PreAuth preAuth = getPreAuth(method, point.getTarget().getClass());
if (preAuth == null || preAuth.value() == null || preAuth.value().isBlank()) {
return false;
}
String condition = preAuth.value();
Expression expression = EXPRESSION_PARSER.parseExpression(condition);
StandardEvaluationContext context = getEvaluationContext(method, point.getArgs());
Boolean result = expression.getValue(context, Boolean.class);
return Boolean.TRUE.equals(result);
}
private PreAuth getPreAuth(Method method, Class<?> targetClass) {
PreAuth methodAnnotation = method.getAnnotation(PreAuth.class);
if (methodAnnotation != null) {
return methodAnnotation;
}
return targetClass.getAnnotation(PreAuth.class);
}
private StandardEvaluationContext getEvaluationContext(Method method, Object[] args) {
StandardEvaluationContext context = new StandardEvaluationContext(new AuthFun());
context.setBeanResolver(new BeanFactoryResolver(applicationContext));
String[] parameterNames = PARAMETER_NAME_DISCOVERER.getParameterNames(method);
if (parameterNames != null) {
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
}
return context;
}
}十、方法注解优先于类注解
切面中读取注解时,建议方法上的注解优先级更高。
private PreAuth getPreAuth(Method method, Class<?> targetClass) {
PreAuth methodAnnotation = method.getAnnotation(PreAuth.class);
if (methodAnnotation != null) {
return methodAnnotation;
}
return targetClass.getAnnotation(PreAuth.class);
}
这样可以实现:
@PreAuth("hasAuth()")
@RestController
@RequestMapping("/user")
public class UserController {
@PreAuth("hasRole('管理员')")
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
// 删除用户
}
}
类上要求登录即可访问。
但删除接口要求管理员角色。
这更符合实际业务需求。
十一、注册权限函数对象
SpEL 能调用 AuthFun 中的方法,是因为这里把 AuthFun 作为了表达式上下文的 root object:
StandardEvaluationContext context = new StandardEvaluationContext(new AuthFun());因此表达式:
hasRole('管理员')会被解析成调用:
AuthFun.hasRole("管理员")表达式:
hasAllRole('管理员', '总工程师')会被解析成:
AuthFun.hasAllRole("管理员", "总工程师")这就是使用 SpEL 做权限表达式的核心。
十二、支持方法参数
有些权限判断需要用到接口参数。
例如:
@PreAuth("hasPermission('USER.UPDATE') && #userId == currentUserId()")
@PutMapping("/user/{userId}")
public void updateUser(@PathVariable Long userId) {
// ...
}
为了支持 #userId,切面中需要把方法参数放入上下文。
String[] parameterNames = PARAMETER_NAME_DISCOVERER.getParameterNames(method);
if (parameterNames != null) {
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
}这样 SpEL 表达式中就可以使用:
#userId
#orderId
#request
#dto如果要使用参数名,需要确保项目编译时保留参数名信息。
Maven 可以配置:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>十三、扩展更多权限函数
可以继续在 AuthFun 中增加方法。
例如当前用户 ID:
public Long currentUserId() {
LoginUser user = AuthContext.getUser();
return user == null ? null : user.getUserId();
}判断是否本人:
public boolean isSelf(Long userId) {
LoginUser user = AuthContext.getUser();
return user != null && user.getUserId().equals(userId);
}然后可以这样使用:
@PreAuth("isSelf(#userId) || hasRole('管理员')")表示本人或管理员可以访问。
再比如判断数据权限:
public boolean hasDataScope(Long deptId) {
LoginUser user = AuthContext.getUser();
if (user == null) {
return false;
}
return user.getDeptIds().contains(deptId);
}使用方式:
@PreAuth("hasDataScope(#deptId)")这就是 SpEL 的灵活性。
权限规则可以逐步扩展,而不用频繁改切面代码。
十四、实际使用示例
1. 登录后才能访问
@PreAuth("hasAuth()")
@GetMapping("/profile")
public UserVO profile() {
return userService.profile();
}2. 拥有权限编码才能访问
@PreAuth("hasPermission('USER.QUERY')")
@GetMapping("/user/list")
public List<UserVO> list() {
return userService.list();
}3. 管理员才能访问
@PreAuth("hasRole('管理员')")
@DeleteMapping("/user/{id}")
public void delete(@PathVariable Long id) {
userService.delete(id);
}4. 拥有任意一个角色即可访问
@PreAuth("hasAnyRole('管理员', '运营')")
@GetMapping("/operation/data")
public Object operationData() {
return operationService.queryData();
}5. 同时拥有多个角色才能访问
@PreAuth("hasAllRole('管理员', '总工程师')")
@PostMapping("/system/config")
public void updateConfig() {
systemService.updateConfig();
}6. 本人或管理员可以访问
@PreAuth("isSelf(#userId) || hasRole('管理员')")
@GetMapping("/user/{userId}")
public UserVO detail(@PathVariable Long userId) {
return userService.detail(userId);
}7. 指定时间段内可以访问
@PreAuth("hasTimeAuth(9, 18)")
@GetMapping("/report/export")
public void exportReport() {
reportService.export();
}十五、这种方案的优点
1. 权限表达更灵活
复杂权限可以写在注解里。
例如:
@PreAuth("hasPermission('ORDER.UPDATE') && hasRole('管理员')")不需要为每种组合单独写注解。
2. 切面逻辑更稳定
切面只负责:
读取注解
解析表达式
执行表达式
判断结果具体权限逻辑下沉到 AuthFun 中。
3. 扩展成本低
新增一种权限判断时,只需要在 AuthFun 中新增方法。
例如:
hasDeptPermission(...)
hasTenantPermission(...)
hasOwnerPermission(...)原来的切面不用改。
4. 可读性较好
接口上可以直接看到权限规则。
例如:
@PreAuth("isSelf(#userId) || hasRole('管理员')")比单纯写一个权限编码更直观。
十六、需要注意的问题
1. 表达式不要过度复杂
虽然 SpEL 很灵活,但不建议把非常复杂的业务逻辑都写在注解里。
不推荐:
@PreAuth("hasRole('A') && hasPermission('B') && hasDataScope(#deptId) && hasTimeAuth(9, 18) && ...")表达式太长会影响可读性。
复杂逻辑可以封装成一个权限函数。
例如:
@PreAuth("canUpdateOrder(#orderId)")然后在 AuthFun 中实现:
public boolean canUpdateOrder(Long orderId) {
// 复杂判断逻辑
}2. 权限函数中不要写太重的逻辑
权限判断会在接口调用前执行。
如果权限函数中频繁查数据库,可能影响接口性能。
建议:
用户角色和权限在登录后缓存;
常用权限信息放到 Token 或 Redis;
数据权限可以按需查询,但要注意性能;
高频接口避免复杂权限表达式。
3. 注意方法参数名获取
如果表达式中使用:
#userId需要确保运行时能获取方法参数名。
建议 Maven 开启:
<parameters>true</parameters>否则可能获取不到真实参数名。
4. 类注解和方法注解优先级要明确
建议统一约定:
方法注解优先于类注解这样局部接口可以覆盖类上的通用权限规则。
5. 异常返回要统一
示例中直接抛出:
throw new RuntimeException("请求无权限访问");实际项目中建议使用统一业务异常。
例如:
throw new BizException("请求无权限访问");再由全局异常处理器转换成统一响应结构。
6. SpEL 表达式不要来自用户输入
权限表达式应该写在服务端代码注解中。
不要允许用户从请求参数中传入 SpEL 表达式并执行。
否则可能带来安全风险。
结论
在 Spring Boot 中,使用自定义注解 + AOP 可以实现接口权限控制。
但当权限规则变复杂时,单纯依赖固定注解参数会显得不够灵活。
引入 SpEL 后,可以把权限规则表达成:
@PreAuth("hasRole('管理员')")
@PreAuth("hasPermission('USER.QUERY')")
@PreAuth("isSelf(#userId) || hasRole('管理员')")
整体实现思路是:
定义
@PreAuth注解;在注解中填写 SpEL 权限表达式;
使用 AOP 拦截方法调用;
读取注解中的表达式;
使用
SpelExpressionParser解析表达式;将
AuthFun注册为表达式 root object;在
AuthFun中实现具体权限判断方法;表达式返回
true放行,返回false拒绝访问。
SpEL 适合用来表达灵活的权限规则。把权限判断函数集中到 AuthFun 中,再通过注解表达式调用,可以让接口权限控制既灵活又容易扩展。