Spring Boot 使用 SpEL 实现接口权限控制

作者:old wang 发布时间: 2021-06-03 阅读量:4 评论数:0

在 Spring Boot 项目中,接口权限控制是很常见的需求。

常规做法一般是:

  1. 定义一个权限校验注解;

  2. 在接口方法上添加注解;

  3. 使用 AOP 拦截接口调用;

  4. 在切面中判断当前用户是否有权限;

  5. 有权限则放行,没有权限则拒绝访问。

这种方式适合简单权限判断。

但在实际业务中,权限规则往往不是单一的。

例如:

  • 登录后才允许访问;

  • 只要有任意角色即可访问;

  • 必须拥有某个权限编码;

  • 必须拥有某个角色;

  • 必须同时拥有多个角色;

  • 只有超级管理员可以访问;

  • 某些接口允许所有人访问;

  • 某些接口只允许指定时间段访问。

如果每种场景都单独写一个注解或在切面中写大量 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 拒绝

核心组件包括:

  1. @PreAuth:权限注解;

  2. AuthFun:权限函数类;

  3. PreAuthAspect:权限切面;

  4. SpelExpressionParser:SpEL 表达式解析器;

  5. 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 切面。

切面需要拦截:

  1. 方法上的 @PreAuth

  2. 类上的 @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('管理员')")

整体实现思路是:

  1. 定义 @PreAuth 注解;

  2. 在注解中填写 SpEL 权限表达式;

  3. 使用 AOP 拦截方法调用;

  4. 读取注解中的表达式;

  5. 使用 SpelExpressionParser 解析表达式;

  6. AuthFun 注册为表达式 root object;

  7. AuthFun 中实现具体权限判断方法;

  8. 表达式返回 true 放行,返回 false 拒绝访问。

SpEL 适合用来表达灵活的权限规则。把权限判断函数集中到 AuthFun 中,再通过注解表达式调用,可以让接口权限控制既灵活又容易扩展。

评论