SpringBoot:使用AOP对API请求授权验证 - George


在今天的文章中,我将讨论如何利用 Spring AOP 在端点级别授权 API 请求。
假设我们构建了一个 API 来跟踪启用了基本身份验证的 Spring Security 的每月费用,并且我们希望根据经过身份验证的用户的权限来授权请求​​。
简而言之,身份验证是验证用户身份以确定他们声称的身份的过程,授权 是验证用户的权限/角色/权限以访问特定资源的过程。
为简单起见,我们 只有两个权限:USER并且ADMIN我们可以考虑身份验证过程已经根据用户名/密码组合使用正确的授予权限填充 Spring Security Context。

public enum SecurityAuthorities {
  USER,
  ADMIN
}

我们的一些 API 端点需要USER权限,而其他端点需要权限ADMIN(用于用户管理和其他管理要求)。
我们如何获得自定义权限的授权?
使用 Spring,我们可以@PreAuthorize在端点级别使用众所周知的注释:

@PreAuthorize("hasAuthority('USER')")
@GetMapping(
"/api/v1/expenses", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(OK)
public ResponseEntity<GetExpensesResponseDto> getExpenses(){
   
// irrelevant code here
}

正如您所看到的,我们hasAuthority('USER')为@PreAuthorize注释指定了 值,它转换为:经过身份验证的用户必须具有USER访问此端点的权限。如果缺少此权限,则将返回 403 Forbidden。
将权限值指定为纯文本的主要问题是可维护性以及容易出现拼写错误和破坏性更改的事实。想象一下,我们在SecurityAuthorities想将权限名称从USER 重构为CUSTOMER.
这意味着您需要确保找到所有找到'USER'字符串的位置并将其替换为'CUSTOMER'; 再加上你需要在 25 个不同的地方做这件事,这很快就会变得很痛苦。那么为什么不使用我们的枚举类呢?
@PreAuthorize("hasAuthority(T(com.example.SecurityAuthorities).USER)")
@GetMapping(
"/api/v1/expenses", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(OK)
public ResponseEntity<GetExpensesResponseDto> getExpenses(){
   
// irrelevant code here
}

使用 enum 类比纯文本更容易一些,因为在编译时,您可以在完全限定的包名称之后放置正确的值,而无需担心拼写错误。此外,如果您重命名权限名称或将枚举移动到另一个包中,更改将反映在此处......但是如果您删除枚举,编译器根本不会抱怨,这是一个大问题,因为它隐藏了您的端点期望的授权事实上不再存在。
即使@PreAuthorize注释解决了授权过程并且使用起来非常简单,我们仍然需要一个更清晰、更易于维护的解决方案,以便直接使用我们的枚举值,而无需限定包名称或硬编码字符串,同时确保编译时安全。AOP 来拯救你了!
 
AOP 解决方案
长话短说AOP 允许您向现有代码添加额外的行为,而无需修改代码本身。
我们要做的基本上是实现一个方法,该方法将在新请求到达我们的端点时由 AOP 自动调用,并接收一组权限以检查经过身份验证的用户 (the Principal) 以决定授权的结果. 请记住,我们端点的现有代码只会发生一个小改动:用@PreAuthorize自定义的注释替换注释。让我们继续…

首先,我们需要一种方法来指定端点需要检查自定义权限的哪个子集。为此,我们将创建一个自定义注释以直接使用我们的枚举而不是字符串:

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Order(Ordered.HIGHEST_PRECEDENCE)
public @interface HasEndpointAuthorities {

    SecurityAuthorities[] authorities();

}

接下来,我们需要更新我们的端点,如下所示:
@HasEndpointAuthorities(authorities = { SecurityAuthorities.USER })
@GetMapping("/api/v1/expenses", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(OK)
public ResponseEntity<GetExpensesResponseDto> getExpenses(){
   
// irrelevant code here
}

这个自定义注解的好处是,如果我们重构SecurityAuthorities枚举,所有更改都会立即反映,如果我们删除它,编译器会尖叫报错。

在我们定义了我们的自定义注释之后,我们需要创建一个方法,该方法将在针对我们用@HasEndpointAuthorities注释的端点之一的每个请求上调用。

@Aspect
@Component
@Slf4j
public class HasEndpointAuthoritiesAspect {

    @Before("within(@org.springframework.web.bind.annotation.RestController *) && @annotation(authorities)")
    public void hasAuthorities(final JoinPoint joinPoint, final HasEndpointAuthorities authorities) {
        final SecurityContext securityContext = SecurityContextHolder.getContext();
        if (!Objects.isNull(securityContext)) {
            final Authentication authentication = securityContext.getAuthentication();
            if (!Objects.isNull(authentication)) {
                final String username = authentication.getName();

                final Collection<? extends GrantedAuthority> userAuthorities = authentication.getAuthorities();

                if (Stream.of(authorities.authorities()).noneMatch(authorityName -> userAuthorities.stream().anyMatch(userAuthority ->
                        authorityName.name().equals(userAuthority.getAuthority())))) {

                    log.error(
"User {} does not have the correct authorities required by endpoint", username);
                    throw new ApiException(DefaultExceptionReason.FORBIDDEN);
                }
            } else {
                log.error(
"The authentication is null when checking endpoint access for user request");
                throw new ApiException(DefaultExceptionReason.UNAUTHORIZED);
            }
        } else {
            log.error(
"The security context is null when checking endpoint access for user request");
            throw new ApiException(DefaultExceptionReason.FORBIDDEN);
        }
    }


}

注意类别的的注释@Aspect和方法级别的@Before注释:第一个注释了 Spring AOP 将使用的类,第二个解释如下:
@Before注释是在被@hasAuthorities注释了的方法倍调用之前调用, /api/v1/expenses端点如果被访问,在真正调用该API对应的方法getExpenses 之前hasAuthorities 必须首先被调用。如果hasAuthorities 抛错如403, 真正业务方法getExpenses方法也不会被调用,403将作为响应返回。
就是这样!您现在可以通过自定义注释使用一行代码来控制检查每个端点的权限,并且您还可以充分利用枚举类。