Spring Security 优化鉴权注解,使用自定义鉴权注解替代@PreAuthorize

引言

众所周知,在SpringSecurity的鉴权体系中,最常用的就是使用 @PreAuthorize 进行鉴权:

java 复制代码
@Service
public class MyCustomerService {
    @PreAuthorize("hasAuthority('permission:read')")
    @PostAuthorize("returnObject.owner == authentication.name")
    public Customer readCustomer(String id) { ... }
}

@PreAuthorize 注解本身使用SpEL表达式进行解析,其中hasAuthority 方式是位于#rootSpEL Context 中 rootObject 对象的方法 即SecurityExpressionRoot 继承自SecurityExpressionOperations接口的方法

尽管SpEL表达式相当灵活,可以调用Bean,可以调用静态方法,但是对于常见的判断当前用户有没有指定权限的写法而言相对啰嗦: 通常来说,权限字符串应该是静态定声明在常量类中,但如果要在SpEL表达式中引用,就需要

java 复制代码
@Service
public class MyCustomerService {
    @PreAuthorize("hasAnyAuthority('" + PermissionCode.PROJECT + "')")
    public Customer readCustomer(String id) { ... }
    //或者
    @PreAuthorize("hasAnyAuthority(T(com.xxx.constant.PermissionCode).PROJECT)")
    public Customer readCustomer2(String id) { ... }
}

特别是,如果需要引用多个权限字符串,编写起来相当麻烦

java 复制代码
@Service
public class MyCustomerService {
    @PreAuthorize("hasAnyAuthority('" + PermissionCode.DATA_FILLING + "','" + PermissionCode.ASSEMBLY + "')")
    public Customer readCustomer(String id) { ... }
}
    @PreAuthorize("hasAnyAuthority(T(com.xxx.constant.PermissionCode).DATA_FILLING,T(com.xxx.constant.PermissionCode).ASSEMBLY)")
    public Customer readCustomer2(String id) { ... }
}

可谓相当啰嗦且容易出错,而且这只是最基础的权限,如果包含租户/组织架构的权限判定,参数就会更加复杂,这就需要一个自定义的鉴权注解来替代@PreAuthorize 简化鉴权编码

自定义注解

java 复制代码
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@PreAuthorize("@authorizeCheck.checkAuthority(#root)")
public @interface Authorize {
    /**
     * 权限编码集合
     */
    String[] value();

    /**
     * 租户ID的SpEL表达式
     */
    @Language("SpEL") String tenantId() default "";

    /**
     * 多个权限校验码之间是否只要有一个满足即可还是必须全部满足
     */
    boolean anyMatch() default true;
}

其中最重要的就是@PreAuthorize("@authorizeCheck.checkAuthority(#root)")@PreAuthorize本身是具有元注解功能的,但是其所提供的的功能不够强大,我们不能通过@PreAuthorize在元注解模式下在被调用方法中提取到我们的自定义注解信息,我们需要拓展以达到AOP切面的效果,而不直接使用AOP的原因是,融入SpringSecurity的体系中可以帮助我们减少很多麻烦和处理工作

拓展 @PreAuthorize 的元注解能力

元注解@PreAuthorize("@authorizeCheck.checkAuthority(#root)")中,@authorizeCheck.checkAuthority 是调用名为authorizeCheck的Bean的checkAuthority方法,方法参数为#root对象。

但是默认的#root对象MethodSecurityExpressionRoot并不包含方法信息,target字段也只是方法所在类实例,即上述示例中的 MyCustomerService 对象

java 复制代码
package org.springframework.security.access.expression.method;
class MethodSecurityExpressionRoot extends SecurityExpressionRoot implements MethodSecurityExpressionOperations {
    private Object filterObject;
    private Object returnObject;
    private Object target;
    //...
}
package org.springframework.security.access.expression;
public abstract class SecurityExpressionRoot implements SecurityExpressionOperations {
	private final Supplier<Authentication> authentication;
	private AuthenticationTrustResolver trustResolver;
	private RoleHierarchy roleHierarchy;
	private Set<String> roles;
	private String defaultRolePrefix = "ROLE_";
	public final boolean permitAll = true;
	public final boolean denyAll = false;
	private PermissionEvaluator permissionEvaluator;
	public final String read = "read";
	public final String write = "write";
	public final String create = "create";
	public final String delete = "delete";
	public final String admin = "administration";
        //...
}

我们通过注册一个子类化的DefaultMethodSecurityExpressionHandler的来替换rootObject,使得其包含当前被鉴权的方法信息

我们先创建一个RootObject类

java 复制代码
@Getter
@Setter
public class RootObject extends SecurityExpressionRoot implements MethodSecurityExpressionOperations {
    private Object filterObject;
    private Object returnObject;
    private Object target;
    private MethodInvocation methodInvocation;//重点对象,很可惜默认的root并不传递该对象
    
    public RootObject(Authentication authentication) {//SpringSecurity5写法
        super(authentication);
    }
    public RootObject(Supplier<Authentication> authentication) {//SpringSecurity6写法
        super(authentication);
    }

    public void setThis(Object target) {
        this.target = target;
    }

    @Override
    public Object getThis() {
        return this.target;
    }
}

然后是子类化DefaultMethodSecurityExpressionHandler,如下是SpringSecurity6中的写法,SpringSecurity6中新增了Supplier<Authentication> authentication参数形式的系列方法

java 复制代码
    /**
     * <a href="https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html#subclass-defaultmethodsecurityexpressionhandler">春季安全/Servlet 应用程序/授权/方法安全性/子类化DefaultMethodSecurityExpressionHandler</a>
     */
   public class AuthorizeExpressionHandler extends DefaultMethodSecurityExpressionHandler {
        @Override
        public EvaluationContext createEvaluationContext(Supplier<Authentication> authentication, MethodInvocation mi) {
            // 替换为自定义 ROOT OBJECT
            MethodBasedEvaluationContext context = new MethodBasedEvaluationContext(createSecurityExpressionRoot(authentication, mi)
                    , AopUtils.getMostSpecificMethod(mi.getMethod(), AopProxyUtils.ultimateTargetClass(Objects.requireNonNull(mi.getThis()))), mi.getArguments(), getParameterNameDiscoverer());
            context.setBeanResolver(getBeanResolver());
            return context;
        }

        @Override
        protected MethodSecurityExpressionOperations createSecurityExpressionRoot(Authentication authentication, MethodInvocation invocation) {
            return createSecurityExpressionRoot(() -> authentication, invocation);
        }
        //创建自定义 ROOT OBJECT
        private MethodSecurityExpressionOperations createSecurityExpressionRoot(Supplier<Authentication> authentication,
                                                                                MethodInvocation invocation) {
            RootObject root = new RootObject(authentication);
            root.setMethodInvocation(invocation);
            root.setThis(invocation.getThis());
            root.setPermissionEvaluator(getPermissionEvaluator());
            root.setTrustResolver(getTrustResolver());
            root.setRoleHierarchy(getRoleHierarchy());
            root.setDefaultRolePrefix(getDefaultRolePrefix());
            return root;
        }
    }

然后将该Handler注册为Bean即可

java 复制代码
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(ApplicationContext context) {
    AuthorizeExpressionHandler expressionHandler = new AuthorizeExpressionHandler();
    expressionHandler.setApplicationContext(context);
    return expressionHandler;
}

这样一来,@PreAuthorize("@authorizeCheck.checkAuthority(#root)")#root对象就是携带方法信息的我们自定义的rootObject

自定义鉴权方法读取自定义注解

创建一个类用于执行校验逻辑的Service

java 复制代码
@Service("authorizeCheck") // Bean名称要和@PreAuthorize("@authorizeCheck.checkAuthority(#root)")中一样
@RequiredArgsConstructor
public class AuthorizeCheckSerivce {
    private final UserService userService;

    /**
     * 通过自定义注解{@link Authorize}判断权限<br>
     *
     * @param root SpEL 中的#root
     * @return 是否有权限
     */
    public boolean checkAuthority(RootObject root) {
        MethodInvocation methodInvocation = root.getMethodInvocation();
        if (methodInvocation == null) {
            return true;
        }
        Authorize annotation;
        if (methodInvocation.getMethod().isAnnotationPresent(Authorize.class)) {
            annotation = methodInvocation.getMethod().getAnnotation(Authorize.class);
        } else if (methodInvocation.getMethod().getDeclaringClass().isAnnotationPresent(Authorize.class)) {
            annotation = methodInvocation.getMethod().getDeclaringClass().getAnnotation(Authorize.class);
        } else {
            return true;
        }
        String name = root.getAuthentication().getName();
        UserEntity userEntity = userService.loadUserByUsername(name);//自己系统的用户对象
        if (userEntity == null) {
            return false;
        }
        List<String> authorities;
        // 自定义注解中如果有一些自己系统的特别参数,就在这里处理
        if (StringUtils.hasText(annotation.tenantId())) {
             // 使用 SpEL 表达式工具读取当前调用方法参数中的租户ID
            @Nullable Long tenantId = SpELUtil.getExpression(annotation.tenantId(), root.getMethodInvocation()).getValue(Long.class);
            // 提取对应租户权限
            authorities = userEntity.getAuthorities().stream()
                        .filter(e -> e instanceof TenantAuthority authority
                                     && authority.tenantId().equals(tenantId))
                        .map(Authority::getAuthority).distinct().toList();
        } else {
            authorities = userEntity.getAuthorities().stream().map(Authority::getAuthority).toList();
        }
        String[] needAuthorities = annotation.value();
        if (annotation.anyMatch()) {
            for (String needAuthority : needAuthorities) {
                if (authorities.contains(needAuthority)) {
                    return true;
                }
            }
            return false;
        }
        for (String needAuthority : needAuthorities) {
            if (!authorities.contains(needAuthority)) {
                return false;
            }
        }
        return true;
    }
}

使用自定义注解

这样我们可以使用灵活的方式使用自定义注解,而不是编写冗长的SpEL了

java 复制代码
@Service
public class MyCustomerService {
    @Authorize(PermissionCode.PROJECT)
    public Customer readCustomer(String id) { ... }

    @Authorize({PermissionCode.PROJECT, PermissionCode.DATA_FILLING})
    public Customer readCustomer2(String id) { ... }
    
    @Authorize(value = {PermissionCode.PROJECT,PermissionCode.DATA_FILLING}, anyMatch = false)
    public Customer readCustomer3(String id) { ... }
    
    @Authorize(value = PermissionCode.PROJECT,tenantId = "#p0")
    public Customer readCustomer4(Long tenantId, String id) { ... }
}
相关推荐
会说法语的猪22 分钟前
springboot实现图片上传、下载功能
java·spring boot·后端
凡人的AI工具箱32 分钟前
每天40分玩转Django:实操多语言博客
人工智能·后端·python·django·sqlite
Cachel wood1 小时前
Django REST framework (DRF)中的api_view和APIView权限控制
javascript·vue.js·后端·python·ui·django·前端框架
m0_748234081 小时前
Spring Boot教程之三十一:入门 Web
前端·spring boot·后端
想成为高手4991 小时前
国产之光--仓颉编程语言的实战案例分析
后端
编码浪子2 小时前
构建一个rust生产应用读书笔记7-确认邮件2
开发语言·后端·rust
昙鱼2 小时前
springboot创建web项目
java·前端·spring boot·后端·spring·maven
白宇横流学长2 小时前
基于SpringBoot的停车场管理系统设计与实现【源码+文档+部署讲解】
java·spring boot·后端
kirito学长-Java2 小时前
springboot/ssm太原学院商铺管理系统Java代码编写web在线购物商城
java·spring boot·后端
程序猿-瑞瑞3 小时前
24 go语言(golang) - gorm框架安装及使用案例详解
开发语言·后端·golang·gorm